Merge remote-tracking branch 'upstream/main' into feat/ack-reaction
# Conflicts: # pkg/channels/base.go # pkg/channels/feishu_32.go # pkg/config/config.go
This commit is contained in:
commit
64aec232ce
182 changed files with 26572 additions and 4069 deletions
|
|
@ -5,6 +5,7 @@
|
|||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
# CEREBRAS_API_KEY=xxx
|
||||
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
|
|
|
|||
35
.github/pull_request_template.md
vendored
35
.github/pull_request_template.md
vendored
|
|
@ -1,30 +1,41 @@
|
|||
## 📝 Description
|
||||
|
||||
<!-- Please briefly describe the changes and purpose of this PR -->
|
||||
|
||||
## 🗣️ Type of Change
|
||||
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 📖 Documentation update
|
||||
- [ ] ⚡ Code refactoring (no functional changes, no api changes)
|
||||
|
||||
## 🤖 AI Code Generation
|
||||
- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
|
||||
- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
|
||||
- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
|
||||
|
||||
|
||||
## 🔗 Related Issue
|
||||
|
||||
<!-- Please link the related issue(s) (e.g., Fixes #123, Closes #456) -->
|
||||
|
||||
## 🔗 Linked Issue
|
||||
## 📚 Technical Context (Skip for Docs)
|
||||
* **Reference:** [URL]
|
||||
* **Reasoning:** ...
|
||||
- **Reference URL:**
|
||||
- **Reasoning:**
|
||||
|
||||
## 🧪 Test Environment
|
||||
- **Hardware:** <!-- e.g. Raspberry Pi 5, Orange Pi, PC-->
|
||||
- **OS:** <!-- e.g. Debian 12, Ubuntu 22.04 -->
|
||||
- **Model/Provider:** <!-- e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3 -->
|
||||
- **Channels:** <!-- e.g. Discord, Telegram, Feishu, ... -->
|
||||
|
||||
|
||||
## 🧪 Test Environment & Hardware
|
||||
- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC]
|
||||
- **OS:** [e.g. Debian 12, Ubuntu 22.04]
|
||||
- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3]
|
||||
- **Channels:** [e.g. Discord, Telegram, Feishu, ...]
|
||||
|
||||
|
||||
## 📸 Proof of Work (Optional for Docs)
|
||||
## 📸 Evidence (Optional)
|
||||
<details>
|
||||
<summary>Click to view Logs/Screenshots</summary>
|
||||
|
||||
</details>
|
||||
<!-- Please paste relevant screenshots or logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
## ☑️ Checklist
|
||||
- [ ] My code/docs follow the style of this project.
|
||||
|
|
|
|||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
|
|
@ -2,24 +2,19 @@ name: build
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: fmt
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
- name: Build
|
||||
run: make build-all
|
||||
|
|
|
|||
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
steps:
|
||||
# ── Checkout ──────────────────────────────
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
|
|
|
|||
41
.github/workflows/pr.yml
vendored
41
.github/workflows/pr.yml
vendored
|
|
@ -1,52 +1,38 @@
|
|||
name: pr-check
|
||||
name: PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request: { }
|
||||
|
||||
jobs:
|
||||
fmt-check:
|
||||
lint:
|
||||
name: Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
vet:
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
- name: Golangci Lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
|
@ -55,4 +41,3 @@ jobs:
|
|||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
|
||||
|
|
|
|||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -26,17 +26,19 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push tag
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}"
|
||||
git push origin "${{ inputs.tag }}"
|
||||
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
|
||||
release:
|
||||
name: GoReleaser Release
|
||||
|
|
@ -47,13 +49,14 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v5
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
|
@ -87,6 +90,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Apply release flags
|
||||
shell: bash
|
||||
|
|
|
|||
182
.golangci.yaml
Normal file
182
.golangci.yaml
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# TODO: Tweak for current project needs
|
||||
- containedctx
|
||||
- cyclop
|
||||
- depguard
|
||||
- dupl
|
||||
- dupword
|
||||
- err113
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- gochecknoglobals
|
||||
- godot
|
||||
- intrange
|
||||
- ireturn
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nonamedreturns
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
- wsl_v5
|
||||
|
||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- dogsled
|
||||
- embeddedstructfieldcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funlen
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godox
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- ineffassign
|
||||
- lll
|
||||
- maintidx
|
||||
- misspell
|
||||
- mnd
|
||||
- modernize
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilnil
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- staticcheck
|
||||
- tagalign
|
||||
- testifylint
|
||||
- thelper
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- wastedassign
|
||||
- whitespace
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
funlen:
|
||||
lines: 120
|
||||
statements: 40
|
||||
gocognit:
|
||||
min-complexity: 25
|
||||
gocyclo:
|
||||
min-complexity: 20
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
lll:
|
||||
line-length: 120
|
||||
tab-width: 4
|
||||
misspell:
|
||||
locale: US
|
||||
mnd:
|
||||
checks:
|
||||
- argument
|
||||
- assign
|
||||
- case
|
||||
- condition
|
||||
- operation
|
||||
- return
|
||||
nakedret:
|
||||
max-func-lines: 3
|
||||
revive:
|
||||
enable-all-rules: true
|
||||
rules:
|
||||
- name: add-constant
|
||||
disabled: true
|
||||
- name: argument-limit
|
||||
arguments:
|
||||
- 7
|
||||
severity: warning
|
||||
- name: banned-characters
|
||||
disabled: true
|
||||
- name: cognitive-complexity
|
||||
disabled: true
|
||||
- name: comment-spacings
|
||||
arguments:
|
||||
- nolint
|
||||
severity: warning
|
||||
- name: cyclomatic
|
||||
disabled: true
|
||||
- name: file-header
|
||||
disabled: true
|
||||
- name: function-result-limit
|
||||
arguments:
|
||||
- 3
|
||||
severity: warning
|
||||
- name: function-length
|
||||
disabled: true
|
||||
- name: line-length-limit
|
||||
disabled: true
|
||||
- name: max-public-structs
|
||||
disabled: true
|
||||
- name: modifies-value-receiver
|
||||
disabled: true
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
- name: unused-receiver
|
||||
disabled: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- linters:
|
||||
- lll
|
||||
source: '^//go:generate '
|
||||
- linters:
|
||||
- funlen
|
||||
- maintidx
|
||||
- gocognit
|
||||
- gocyclo
|
||||
path: _test\.go$
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
- golines
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
custom-order: true
|
||||
gofmt:
|
||||
simplify: true
|
||||
rewrite-rules:
|
||||
- pattern: "interface{}"
|
||||
replacement: "any"
|
||||
- pattern: "a[b:len(a)]"
|
||||
replacement: "a[b:]"
|
||||
golines:
|
||||
max-len: 120
|
||||
|
|
@ -11,6 +11,14 @@ builds:
|
|||
- id: picoclaw
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{ .Version }}
|
||||
- -X main.gitCommit={{ .ShortCommit }}
|
||||
- -X main.buildTime={{ .Date }}
|
||||
- -X main.goVersion={{ .Env.GOVERSION }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Create picoclaw home directory
|
||||
# Create non-root user and group
|
||||
RUN addgroup -g 1000 picoclaw && \
|
||||
adduser -D -u 1000 -G picoclaw picoclaw
|
||||
|
||||
# Switch to non-root user
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
|
|
|
|||
18
Makefile
18
Makefile
|
|
@ -11,11 +11,14 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
|
||||
|
||||
# Go variables
|
||||
GO?=go
|
||||
GOFLAGS?=-v
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
||||
# Installation
|
||||
INSTALL_PREFIX?=$(HOME)/.local
|
||||
|
|
@ -39,6 +42,8 @@ ifeq ($(UNAME_S),Linux)
|
|||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),loongarch64)
|
||||
ARCH=loong64
|
||||
else ifeq ($(UNAME_M),riscv64)
|
||||
ARCH=riscv64
|
||||
else
|
||||
|
|
@ -84,6 +89,7 @@ build-all: generate
|
|||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=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)
|
||||
|
|
@ -123,13 +129,17 @@ clean:
|
|||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
## test: Test Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
@$(GO) fmt ./...
|
||||
@$(GOLANGCI_LINT) fmt
|
||||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
|
|
|
|||
1127
README.fr.md
Normal file
1127
README.fr.md
Normal file
File diff suppressed because it is too large
Load diff
338
README.ja.md
338
README.ja.md
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!</h3>
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
|
||||
**日本語** | [English](README.md)
|
||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</table>
|
||||
|
||||
## 📢 ニュース
|
||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
|
||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
|
|
@ -174,44 +174,37 @@ picoclaw onboard
|
|||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
"model": "gpt4"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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) · [Qwen](https://dashscope.console.aliyun.com)
|
||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
**4. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
|
@ -223,7 +216,7 @@ picoclaw agent -m "What is 2+2?"
|
|||
|
||||
## 💬 チャットアプリ
|
||||
|
||||
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||
Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
|
||||
|
||||
| チャネル | セットアップ |
|
||||
|---------|------------|
|
||||
|
|
@ -232,6 +225,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
|||
| **QQ** | 簡単(AppID + AppSecret) |
|
||||
| **DingTalk** | 普通(アプリ認証情報) |
|
||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||
| **WeCom** | 普通(CorpID + Webhook設定) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
|
@ -250,7 +244,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -290,7 +284,7 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -427,6 +421,87 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WeCom (企業微信)</b></summary>
|
||||
|
||||
PicoClaw は2種類の WeCom 統合をサポートしています:
|
||||
|
||||
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
|
||||
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
|
||||
|
||||
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
|
||||
|
||||
**クイックセットアップ - WeCom Bot:**
|
||||
|
||||
**1. ボットを作成**
|
||||
|
||||
* WeCom 管理コンソール → グループチャット → グループボットを追加
|
||||
* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_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",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**クイックセットアップ - WeCom App:**
|
||||
|
||||
**1. アプリを作成**
|
||||
|
||||
* WeCom 管理コンソール → アプリ管理 → アプリを作成
|
||||
* **AgentId** と **Secret** をコピー
|
||||
* "マイ会社" ページで **CorpID** をコピー
|
||||
|
||||
**2. メッセージ受信を設定**
|
||||
|
||||
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||
* URL を `http://your-server:18792/webhook/wecom-app` に設定
|
||||
* **Token** と **EncodingAESKey** を生成
|
||||
|
||||
**3. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
設定ファイル: `~/.picoclaw/config.json`
|
||||
|
|
@ -618,6 +693,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### プロバイダー
|
||||
|
||||
> [!NOTE]
|
||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
|
||||
|
||||
| プロバイダー | 用途 | API キー取得先 |
|
||||
| --- | --- | --- |
|
||||
| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
|
||||
| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek`(要テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
### 基本設定
|
||||
|
||||
1. **設定ファイルの作成:**
|
||||
|
|
@ -663,17 +754,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "gsk_xxx"
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allowFrom": ["123456789"]
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
|
|
@ -685,18 +776,21 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allowFrom": []
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
|
|
@ -708,6 +802,163 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
|
||||
</details>
|
||||
|
||||
### モデル設定 (model_list)
|
||||
|
||||
> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
|
||||
|
||||
この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
|
||||
|
||||
- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
|
||||
- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
|
||||
- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
|
||||
- **集中設定管理** : すべてのプロバイダーを一箇所で管理
|
||||
|
||||
#### 📋 サポートされているすべてのベンダー
|
||||
|
||||
| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
|
||||
|-------------|-----------------|---------------------|----------|---------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
|
||||
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) |
|
||||
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### 基本設定
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-zhipu-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ベンダー別の例
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Zhipu AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (OAuth使用)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||
|
||||
#### ロードバランシング
|
||||
|
||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 従来の `providers` 設定からの移行
|
||||
|
||||
古い `providers` 設定は**非推奨**ですが、後方互換性のためにサポートされています。
|
||||
|
||||
**旧設定(非推奨):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新設定(推奨):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。
|
||||
|
||||
## CLI リファレンス
|
||||
|
||||
| コマンド | 説明 |
|
||||
|
|
@ -729,7 +980,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
|||
|
||||
## 🐛 トラブルシューティング
|
||||
|
||||
### Web 検索で「API 配置问题」と表示される
|
||||
### Web 検索で「API 設定の問題」と表示される
|
||||
|
||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||
|
||||
|
|
@ -740,9 +991,14 @@ Web 検索を有効にするには:
|
|||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -765,5 +1021,7 @@ Web 検索を有効にするには:
|
|||
|---------|--------|------------|
|
||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||
|
|
|
|||
333
README.md
333
README.md
|
|
@ -14,7 +14,7 @@
|
|||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
|
||||
## 📢 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](doc/picoclaw_community_roadmap_260216.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](docs/picoclaw_community_roadmap_260216.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 come 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.
|
||||
|
|
@ -99,6 +99,20 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Run on old Android Phones
|
||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||
2. **Execute cmds**
|
||||
```bash
|
||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||
chmod +x picoclaw-linux-arm64
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||
```
|
||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Innovative Low-Footprint Deploy
|
||||
|
||||
PicoClaw can be deployed on almost any Linux device!
|
||||
|
|
@ -195,18 +209,24 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"model": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "your-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "your-anthropic-key"
|
||||
}
|
||||
},
|
||||
],
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
|
|
@ -223,6 +243,8 @@ picoclaw onboard
|
|||
}
|
||||
```
|
||||
|
||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||
|
||||
**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)
|
||||
|
|
@ -242,7 +264,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||
|
||||
| Channel | Setup |
|
||||
| ------------ | ---------------------------------- |
|
||||
|
|
@ -251,6 +273,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
|||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **LINE** | Medium (credentials + webhook URL) |
|
||||
| **WeCom** | Medium (CorpID + webhook setup) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
|
@ -269,7 +292,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -312,7 +335,8 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"mention_only": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -325,6 +349,10 @@ picoclaw gateway
|
|||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* Open the generated invite URL and add the bot to your server
|
||||
|
||||
**Optional: Mention-only 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.
|
||||
|
||||
**6. Run**
|
||||
|
||||
```bash
|
||||
|
|
@ -390,7 +418,7 @@ picoclaw gateway
|
|||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
|
||||
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
|
|
@ -450,6 +478,87 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
PicoClaw supports two types of WeCom integration:
|
||||
|
||||
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
|
||||
**Option 2: WeCom App (自建应用)** - More features, proactive messaging
|
||||
|
||||
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
|
||||
|
||||
**Quick Setup - WeCom Bot:**
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
* Go to WeCom Admin Console → Group Chat → Add Group Bot
|
||||
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_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",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Quick Setup - WeCom App:**
|
||||
|
||||
**1. Create an app**
|
||||
|
||||
* Go to WeCom Admin Console → App Management → Create App
|
||||
* Copy **AgentId** and **Secret**
|
||||
* Go to "My Company" page, copy **CorpID**
|
||||
|
||||
**2. Configure receive message**
|
||||
|
||||
* In App details, click "Receive Message" → "Set API"
|
||||
* Set URL to `http://your-server:18792/webhook/wecom-app`
|
||||
* Generate **Token** and **EncodingAESKey**
|
||||
|
||||
**3. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||
|
||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||
|
|
@ -663,7 +772,203 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
### Model Configuration (model_list)
|
||||
|
||||
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
|
||||
|
||||
This design also enables **multi-agent support** with flexible provider selection:
|
||||
|
||||
- **Different agents, different providers**: Each agent can use its own LLM provider
|
||||
- **Model fallbacks**: Configure primary and fallback models for resilience
|
||||
- **Load balancing**: Distribute requests across multiple endpoints
|
||||
- **Centralized configuration**: Manage all providers in one place
|
||||
|
||||
#### 📋 All Supported Vendors
|
||||
|
||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||
|--------|----------------|------------------|----------|---------|
|
||||
| **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) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.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) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **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) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-zhipu-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Vendor-Specific Examples
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek**
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (with API key)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
}
|
||||
```
|
||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||
|
||||
**Ollama (local)**
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Proxy/API**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Load Balancing
|
||||
|
||||
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration from Legacy `providers` Config
|
||||
|
||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||
|
||||
**Old Config (deprecated):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Config (recommended):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
PicoClaw routes providers by protocol family:
|
||||
|
||||
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
|
||||
- Anthropic protocol: Claude-native API behavior.
|
||||
- Codex/OAuth path: OpenAI OAuth/token authentication route.
|
||||
|
||||
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
|
@ -760,6 +1065,9 @@ picoclaw agent -m "Hello"
|
|||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
|
|
@ -856,3 +1164,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
|||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||
|
|
|
|||
1122
README.pt-br.md
Normal file
1122
README.pt-br.md
Normal file
File diff suppressed because it is too large
Load diff
1092
README.vi.md
Normal file
1092
README.vi.md
Normal file
File diff suppressed because it is too large
Load diff
356
README.zh.md
356
README.zh.md
|
|
@ -14,7 +14,7 @@
|
|||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [English](README.md)
|
||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
|
||||
## 📢 新闻 (News)
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](doc/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
|
||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||
|
|
@ -100,6 +100,23 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 在手机上轻松运行
|
||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
||||
1. 先去应用商店下载安装Termux
|
||||
2. 打开后执行指令
|
||||
```bash
|
||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||
chmod +x picoclaw-linux-arm64
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||
```
|
||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
|
|
@ -201,30 +218,46 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"model": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "your-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "your-anthropic-key"
|
||||
}
|
||||
},
|
||||
],
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||
|
||||
**3. 获取 API Key**
|
||||
|
||||
* **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)
|
||||
|
|
@ -245,14 +278,15 @@ picoclaw agent -m "2+2 等于几?"
|
|||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
||||
通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
|
||||
|
||||
| 渠道 | 设置难度 |
|
||||
| --- | --- |
|
||||
| **Telegram** | 简单 (仅需 token) |
|
||||
| **Discord** | 简单 (bot token + intents) |
|
||||
| **QQ** | 简单 (AppID + AppSecret) |
|
||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
||||
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
||||
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (推荐)</summary>
|
||||
|
|
@ -271,7 +305,7 @@ picoclaw agent -m "2+2 等于几?"
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -316,7 +350,8 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"mention_only": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -410,11 +445,93 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>企业微信 (WeCom)</b></summary>
|
||||
|
||||
PicoClaw 支持两种企业微信集成方式:
|
||||
|
||||
**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
|
||||
**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
|
||||
|
||||
详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
|
||||
|
||||
**快速设置 - 智能机器人:**
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往企业微信管理后台 → 群聊 → 添加群机器人
|
||||
* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_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",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**快速设置 - 自建应用:**
|
||||
|
||||
**1. 创建应用**
|
||||
|
||||
* 前往企业微信管理后台 → 应用管理 → 创建应用
|
||||
* 复制 **AgentId** 和 **Secret**
|
||||
* 前往"我的企业"页面,复制 **CorpID**
|
||||
|
||||
**2. 配置接收消息**
|
||||
|
||||
* 在应用详情页,点击"接收消息" → "设置API"
|
||||
* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
|
||||
* 生成 **Token** 和 **EncodingAESKey**
|
||||
|
||||
**3. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
|
|
@ -534,7 +651,193 @@ Agent 读取 HEARTBEAT.md
|
|||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
### 模型配置 (model_list)
|
||||
|
||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
||||
|
||||
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
||||
|
||||
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
|
||||
- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性
|
||||
- **负载均衡**:在多个 API 端点之间分配请求
|
||||
- **集中化配置**:在一个地方管理所有 provider
|
||||
|
||||
#### 📋 所有支持的厂商
|
||||
|
||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||
|------|-------------|---------------|------|--------------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
|
||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### 基础配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-zhipu-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt-5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek**
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (使用 OAuth)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||
|
||||
**Ollama (本地)**
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
**自定义代理/API**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 负载均衡
|
||||
|
||||
为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api1.example.com/v1",
|
||||
"api_key": "sk-key1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_base": "https://api2.example.com/v1",
|
||||
"api_key": "sk-key2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 从旧的 `providers` 配置迁移
|
||||
|
||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||
|
||||
**旧配置(已弃用):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新配置(推荐):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
|
@ -560,8 +863,8 @@ Agent 读取 HEARTBEAT.md
|
|||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
|
@ -624,9 +927,18 @@ picoclaw agent -m "你好"
|
|||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
|
|
@ -689,9 +1001,14 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -719,3 +1036,4 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
||||
116
ROADMAP.md
Normal file
116
ROADMAP.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
|
||||
# 🦐 PicoClaw Roadmap
|
||||
|
||||
> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. Core Optimization: Extreme Lightweight
|
||||
|
||||
*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
|
||||
|
||||
* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
|
||||
* **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
|
||||
* **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
|
||||
* **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
|
||||
|
||||
|
||||
## 🛡️ 2. Security Hardening: Defense in Depth
|
||||
|
||||
*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
|
||||
|
||||
* **Input Defense & Permission Control**
|
||||
* **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
|
||||
* **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
|
||||
* **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
|
||||
|
||||
|
||||
* **Sandboxing & Isolation**
|
||||
* **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
|
||||
* **Context Isolation**: Prevent data leakage between different user sessions or channels.
|
||||
* **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
|
||||
|
||||
|
||||
* **Authentication & Secrets**
|
||||
* **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
|
||||
* **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
|
||||
|
||||
|
||||
|
||||
## 🔌 3. Connectivity: Protocol-First Architecture
|
||||
|
||||
*Connect every model, reach every platform.*
|
||||
|
||||
* **Provider**
|
||||
* [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
|
||||
* **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
|
||||
* **Online Models**: Continued support for frontier closed-source models.
|
||||
|
||||
|
||||
* **Channel**
|
||||
* **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
|
||||
* **Standards**: Support for the **OneBot** protocol.
|
||||
* [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
|
||||
|
||||
|
||||
* **Skill Marketplace**
|
||||
* [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
|
||||
|
||||
|
||||
|
||||
## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
|
||||
|
||||
*Beyond conversation—focusing on action and collaboration.*
|
||||
|
||||
* **Operations**
|
||||
* [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
|
||||
* [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
|
||||
* [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
|
||||
|
||||
|
||||
* **Multi-Agent Collaboration**
|
||||
* [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
|
||||
* [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
|
||||
* [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
|
||||
* [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
|
||||
|
||||
|
||||
|
||||
## 📚 5. Developer Experience (DevEx) & Documentation
|
||||
|
||||
*Lowering the barrier to entry so anyone can deploy in minutes.*
|
||||
|
||||
* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
|
||||
* Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
|
||||
|
||||
|
||||
* **Comprehensive Documentation**
|
||||
* **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
|
||||
* **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
|
||||
* **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
|
||||
|
||||
|
||||
|
||||
## 🤖 6. Engineering: AI-Powered Open Source
|
||||
|
||||
*Born from Vibe Coding, we continue to use AI to accelerate development.*
|
||||
|
||||
* **AI-Enhanced CI/CD**
|
||||
* Integrate AI for automated Code Review, Linting, and PR Labeling.
|
||||
* **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
|
||||
* **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
|
||||
|
||||
|
||||
|
||||
## 🎨 7. Brand & Community
|
||||
|
||||
* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
|
||||
* *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🤝 Call for Contributions
|
||||
|
||||
We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
|
||||
BIN
assets/termux.jpg
Normal file
BIN
assets/termux.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 141 KiB |
181
cmd/picoclaw/cmd_agent.go
Normal file
181
cmd/picoclaw/cmd_agent.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func agentCmd() {
|
||||
message := ""
|
||||
sessionKey := "cli:default"
|
||||
modelOverride := ""
|
||||
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--debug", "-d":
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
case "-m", "--message":
|
||||
if i+1 < len(args) {
|
||||
message = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-s", "--session":
|
||||
if i+1 < len(args) {
|
||||
sessionKey = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--model", "-model":
|
||||
if i+1 < len(args) {
|
||||
modelOverride = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if modelOverride != "" {
|
||||
cfg.Agents.Defaults.Model = modelOverride
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.Model = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
||||
})
|
||||
|
||||
if message != "" {
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\n%s %s\n", logo, response)
|
||||
} else {
|
||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
|
||||
interactiveMode(agentLoop, sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
prompt := fmt.Sprintf("%s You: ", logo)
|
||||
|
||||
rl, err := readline.NewEx(&readline.Config{
|
||||
Prompt: prompt,
|
||||
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
|
||||
HistoryLimit: 100,
|
||||
InterruptPrompt: "^C",
|
||||
EOFPrompt: "exit",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error initializing readline: %v\n", err)
|
||||
fmt.Println("Falling back to simple input mode...")
|
||||
simpleInteractiveMode(agentLoop, sessionKey)
|
||||
return
|
||||
}
|
||||
defer rl.Close()
|
||||
|
||||
for {
|
||||
line, err := rl.Readline()
|
||||
if err != nil {
|
||||
if err == readline.ErrInterrupt || err == io.EOF {
|
||||
fmt.Println("\nGoodbye!")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(line)
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("Goodbye!")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
fmt.Println("\nGoodbye!")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(line)
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("Goodbye!")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||
}
|
||||
}
|
||||
512
cmd/picoclaw/cmd_auth.go
Normal file
512
cmd/picoclaw/cmd_auth.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
||||
|
||||
func authCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
authHelp()
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Args[2] {
|
||||
case "login":
|
||||
authLoginCmd()
|
||||
case "logout":
|
||||
authLogoutCmd()
|
||||
case "status":
|
||||
authStatusCmd()
|
||||
case "models":
|
||||
authModelsCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
||||
authHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func authHelp() {
|
||||
fmt.Println("\nAuth commands:")
|
||||
fmt.Println(" login Login via OAuth or paste token")
|
||||
fmt.Println(" logout Remove stored credentials")
|
||||
fmt.Println(" status Show current auth status")
|
||||
fmt.Println(" models List available Antigravity models")
|
||||
fmt.Println()
|
||||
fmt.Println("Login options:")
|
||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw auth login --provider openai")
|
||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
||||
fmt.Println(" picoclaw auth models")
|
||||
fmt.Println(" picoclaw auth logout --provider openai")
|
||||
fmt.Println(" picoclaw auth status")
|
||||
}
|
||||
|
||||
func authLoginCmd() {
|
||||
provider := ""
|
||||
useDeviceCode := false
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--device-code":
|
||||
useDeviceCode = true
|
||||
}
|
||||
}
|
||||
|
||||
if provider == "" {
|
||||
fmt.Println("Error: --provider is required")
|
||||
fmt.Println(supportedProvidersMsg)
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
authLoginPasteToken(provider)
|
||||
case "google-antigravity", "antigravity":
|
||||
authLoginGoogleAntigravity()
|
||||
default:
|
||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||
fmt.Println(supportedProvidersMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool) {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
var err error
|
||||
|
||||
if useDeviceCode {
|
||||
cred, err = auth.LoginDeviceCode(cfg)
|
||||
} else {
|
||||
cred, err = auth.LoginBrowser(cfg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("openai", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format)
|
||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundOpenAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no openai in ModelList, add it
|
||||
if !foundOpenAI {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model to use OpenAI
|
||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Login successful!")
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||
}
|
||||
fmt.Println("Default model set to: gpt-5.2")
|
||||
}
|
||||
|
||||
func authLoginGoogleAntigravity() {
|
||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
cred, err := auth.LoginBrowser(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cred.Provider = "google-antigravity"
|
||||
|
||||
// Fetch user email from Google userinfo
|
||||
email, err := fetchGoogleUserEmail(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not fetch email: %v\n", err)
|
||||
} else {
|
||||
cred.Email = email
|
||||
fmt.Printf("Email: %s\n", email)
|
||||
}
|
||||
|
||||
// Fetch Cloud Code Assist project ID
|
||||
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
|
||||
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
|
||||
} else {
|
||||
cred.ProjectID = projectID
|
||||
fmt.Printf("Project: %s\n", projectID)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format, for backward compatibility)
|
||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundAntigravity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no antigravity in ModelList, add it
|
||||
if !foundAntigravity {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "gemini-flash"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||
fmt.Println("Default model set to: gemini-flash")
|
||||
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||
}
|
||||
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
|
||||
func authLoginPasteToken(provider string) {
|
||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential(provider, cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||
}
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Token saved for %s!\n", provider)
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
|
||||
}
|
||||
|
||||
func authLogoutCmd() {
|
||||
provider := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider != "" {
|
||||
if err := auth.DeleteCredential(provider); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
// Clear AuthMethod in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear AuthMethod in Providers (legacy)
|
||||
switch provider {
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
case "google-antigravity", "antigravity":
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Printf("Logged out from %s\n", provider)
|
||||
} else {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
// Clear all AuthMethods in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
// Clear all AuthMethods in Providers (legacy)
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out from all providers")
|
||||
}
|
||||
}
|
||||
|
||||
func authStatusCmd() {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading auth store: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(store.Credentials) == 0 {
|
||||
fmt.Println("No authenticated providers.")
|
||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nAuthenticated Providers:")
|
||||
fmt.Println("------------------------")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s:\n", provider)
|
||||
fmt.Printf(" Method: %s\n", cred.AuthMethod)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf(" Account: %s\n", cred.AccountID)
|
||||
}
|
||||
if cred.Email != "" {
|
||||
fmt.Printf(" Email: %s\n", cred.Email)
|
||||
}
|
||||
if cred.ProjectID != "" {
|
||||
fmt.Printf(" Project: %s\n", cred.ProjectID)
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authModelsCmd() {
|
||||
cred, err := auth.GetCredential("google-antigravity")
|
||||
if err != nil || cred == nil {
|
||||
fmt.Println("Not logged in to Google Antigravity.")
|
||||
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh token if needed
|
||||
if cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
|
||||
if refreshErr == nil {
|
||||
cred = refreshed
|
||||
_ = auth.SetCredential("google-antigravity", cred)
|
||||
}
|
||||
}
|
||||
|
||||
projectID := cred.ProjectID
|
||||
if projectID == "" {
|
||||
fmt.Println("No project ID stored. Try logging in again.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||
|
||||
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching models: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
fmt.Println("No models available.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Available Antigravity Models:")
|
||||
fmt.Println("-----------------------------")
|
||||
for _, m := range models {
|
||||
status := "✓"
|
||||
if m.IsExhausted {
|
||||
status = "✗ (quota exhausted)"
|
||||
}
|
||||
name := m.ID
|
||||
if m.DisplayName != "" {
|
||||
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, name)
|
||||
}
|
||||
}
|
||||
|
||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" ||
|
||||
model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") ||
|
||||
strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
|
||||
// isOpenAIModel checks if a model string belongs to openai provider
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" ||
|
||||
strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" ||
|
||||
strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
227
cmd/picoclaw/cmd_cron.go
Normal file
227
cmd/picoclaw/cmd_cron.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func cronCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
cronHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Load config to get workspace path
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
cronListCmd(cronStorePath)
|
||||
case "add":
|
||||
cronAddCmd(cronStorePath)
|
||||
case "remove":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw cron remove <job_id>")
|
||||
return
|
||||
}
|
||||
cronRemoveCmd(cronStorePath, os.Args[3])
|
||||
case "enable":
|
||||
cronEnableCmd(cronStorePath, false)
|
||||
case "disable":
|
||||
cronEnableCmd(cronStorePath, true)
|
||||
default:
|
||||
fmt.Printf("Unknown cron command: %s\n", subcommand)
|
||||
cronHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func cronHelp() {
|
||||
fmt.Println("\nCron commands:")
|
||||
fmt.Println(" list List all scheduled jobs")
|
||||
fmt.Println(" add Add a new scheduled job")
|
||||
fmt.Println(" remove <id> Remove a job by ID")
|
||||
fmt.Println(" enable <id> Enable a job")
|
||||
fmt.Println(" disable <id> Disable a job")
|
||||
fmt.Println()
|
||||
fmt.Println("Add options:")
|
||||
fmt.Println(" -n, --name Job name")
|
||||
fmt.Println(" -m, --message Message for agent")
|
||||
fmt.Println(" -e, --every Run every N seconds")
|
||||
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
|
||||
fmt.Println(" -d, --deliver Deliver response to channel")
|
||||
fmt.Println(" --to Recipient for delivery")
|
||||
fmt.Println(" --channel Channel for delivery")
|
||||
}
|
||||
|
||||
func cronListCmd(storePath string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No scheduled jobs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nScheduled Jobs:")
|
||||
fmt.Println("----------------")
|
||||
for _, job := range jobs {
|
||||
var schedule string
|
||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||
} else if job.Schedule.Kind == "cron" {
|
||||
schedule = job.Schedule.Expr
|
||||
} else {
|
||||
schedule = "one-time"
|
||||
}
|
||||
|
||||
nextRun := "scheduled"
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !job.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||
fmt.Printf(" Schedule: %s\n", schedule)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Next run: %s\n", nextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func cronAddCmd(storePath string) {
|
||||
name := ""
|
||||
message := ""
|
||||
var everySec *int64
|
||||
cronExpr := ""
|
||||
deliver := false
|
||||
channel := ""
|
||||
to := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-n", "--name":
|
||||
if i+1 < len(args) {
|
||||
name = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-m", "--message":
|
||||
if i+1 < len(args) {
|
||||
message = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-e", "--every":
|
||||
if i+1 < len(args) {
|
||||
var sec int64
|
||||
fmt.Sscanf(args[i+1], "%d", &sec)
|
||||
everySec = &sec
|
||||
i++
|
||||
}
|
||||
case "-c", "--cron":
|
||||
if i+1 < len(args) {
|
||||
cronExpr = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-d", "--deliver":
|
||||
deliver = true
|
||||
case "--to":
|
||||
if i+1 < len(args) {
|
||||
to = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--channel":
|
||||
if i+1 < len(args) {
|
||||
channel = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
fmt.Println("Error: --name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
fmt.Println("Error: --message is required")
|
||||
return
|
||||
}
|
||||
|
||||
if everySec == nil && cronExpr == "" {
|
||||
fmt.Println("Error: Either --every or --cron must be specified")
|
||||
return
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
if everySec != nil {
|
||||
everyMS := *everySec * 1000
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "every",
|
||||
EveryMS: &everyMS,
|
||||
}
|
||||
} else {
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "cron",
|
||||
Expr: cronExpr,
|
||||
}
|
||||
}
|
||||
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding job: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||
}
|
||||
|
||||
func cronRemoveCmd(storePath, jobID string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
if cs.RemoveJob(jobID) {
|
||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func cronEnableCmd(storePath string, disable bool) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
||||
return
|
||||
}
|
||||
|
||||
jobID := os.Args[3]
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
enabled := !disable
|
||||
|
||||
job := cs.EnableJob(jobID, enabled)
|
||||
if job != nil {
|
||||
status := "enabled"
|
||||
if disable {
|
||||
status = "disabled"
|
||||
}
|
||||
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
238
cmd/picoclaw/cmd_gateway.go
Normal file
238
cmd/picoclaw/cmd_gateway.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
"github.com/sipeed/picoclaw/pkg/devices"
|
||||
"github.com/sipeed/picoclaw/pkg/health"
|
||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
func gatewayCmd() {
|
||||
// Check for --debug flag
|
||||
args := os.Args[2:]
|
||||
for _, arg := range args {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.Model = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||
fmt.Printf(" • Skills: %d/%d available\n",
|
||||
skillsInfo["available"],
|
||||
skillsInfo["total"])
|
||||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": toolsInfo["count"],
|
||||
"skills_total": skillsInfo["total"],
|
||||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
// Setup cron tool and service
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
cronService := setupCronTool(
|
||||
agentLoop,
|
||||
msgBus,
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
execTimeout,
|
||||
cfg,
|
||||
)
|
||||
|
||||
heartbeatService := heartbeat.NewHeartbeatService(
|
||||
cfg.WorkspacePath(),
|
||||
cfg.Heartbeat.Interval,
|
||||
cfg.Heartbeat.Enabled,
|
||||
)
|
||||
heartbeatService.SetBus(msgBus)
|
||||
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
// Use cli:direct as fallback if no valid channel
|
||||
if channel == "" || chatID == "" {
|
||||
channel, chatID = "cli", "direct"
|
||||
}
|
||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||
var response string
|
||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||
if err != nil {
|
||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
if response == "HEARTBEAT_OK" {
|
||||
return tools.SilentResult("Heartbeat OK")
|
||||
}
|
||||
// For heartbeat, always return silent - the subagent result will be
|
||||
// sent to user via processSystemMessage when the async task completes
|
||||
return tools.SilentResult(response)
|
||||
})
|
||||
|
||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Inject channel manager into agent loop for command handling
|
||||
agentLoop.SetChannelManager(channelManager)
|
||||
|
||||
var transcriber *voice.GroqTranscriber
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
||||
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()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := cronService.Start(); err != nil {
|
||||
fmt.Printf("Error starting cron service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Cron service started")
|
||||
|
||||
if err := heartbeatService.Start(); err != nil {
|
||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
deviceService := devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
deviceService.SetBus(msgBus)
|
||||
if err := deviceService.Start(ctx); err != nil {
|
||||
fmt.Printf("Error starting device service: %v\n", err)
|
||||
} else if cfg.Devices.Enabled {
|
||||
fmt.Println("✓ Device event service started")
|
||||
}
|
||||
|
||||
if err := channelManager.StartAll(ctx); err != nil {
|
||||
fmt.Printf("Error starting channels: %v\n", err)
|
||||
}
|
||||
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
go func() {
|
||||
if err := healthServer.Start(); err != nil && 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)
|
||||
|
||||
go agentLoop.Run(ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
cancel()
|
||||
healthServer.Stop(context.Background())
|
||||
deviceService.Stop()
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
agentLoop.Stop()
|
||||
channelManager.StopAll(ctx)
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
}
|
||||
|
||||
func setupCronTool(
|
||||
agentLoop *agent.AgentLoop,
|
||||
msgBus *bus.MessageBus,
|
||||
workspace string,
|
||||
restrict bool,
|
||||
execTimeout time.Duration,
|
||||
cfg *config.Config,
|
||||
) *cron.CronService {
|
||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||
|
||||
// Create cron service
|
||||
cronService := cron.NewCronService(cronStorePath, nil)
|
||||
|
||||
// Create and register CronTool
|
||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
||||
// Set the onJob handler
|
||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||
result := cronTool.ExecuteJob(context.Background(), job)
|
||||
return result, nil
|
||||
})
|
||||
|
||||
return cronService
|
||||
}
|
||||
81
cmd/picoclaw/cmd_migrate.go
Normal file
81
cmd/picoclaw/cmd_migrate.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||
)
|
||||
|
||||
func migrateCmd() {
|
||||
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
||||
migrateHelp()
|
||||
return
|
||||
}
|
||||
|
||||
opts := migrate.Options{}
|
||||
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--dry-run":
|
||||
opts.DryRun = true
|
||||
case "--config-only":
|
||||
opts.ConfigOnly = true
|
||||
case "--workspace-only":
|
||||
opts.WorkspaceOnly = true
|
||||
case "--force":
|
||||
opts.Force = true
|
||||
case "--refresh":
|
||||
opts.Refresh = true
|
||||
case "--openclaw-home":
|
||||
if i+1 < len(args) {
|
||||
opts.OpenClawHome = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--picoclaw-home":
|
||||
if i+1 < len(args) {
|
||||
opts.PicoClawHome = args[i+1]
|
||||
i++
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown flag: %s\n", args[i])
|
||||
migrateHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := migrate.Run(opts)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.DryRun {
|
||||
migrate.PrintSummary(result)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateHelp() {
|
||||
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: picoclaw migrate [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
||||
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
||||
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
||||
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
||||
fmt.Println(" --force Skip confirmation prompts")
|
||||
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
||||
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
|
||||
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
|
||||
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
|
||||
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
|
||||
}
|
||||
108
cmd/picoclaw/cmd_onboard.go
Normal file
108
cmd/picoclaw/cmd_onboard.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../workspace .
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func onboard() {
|
||||
configPath := getConfigPath()
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Config already exists at %s\n", configPath)
|
||||
fmt.Print("Overwrite? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
fmt.Println("")
|
||||
fmt.Println(" Recommended:")
|
||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||
fmt.Println("")
|
||||
fmt.Println(" See README.md for 17+ supported providers.")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
|
||||
func copyEmbeddedToTarget(targetDir string) error {
|
||||
// Ensure target directory exists
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
// Walk through all files in embed.FS
|
||||
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read embedded file
|
||||
data, err := embeddedFiles.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
|
||||
}
|
||||
|
||||
new_path, err := filepath.Rel("workspace", path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||
}
|
||||
|
||||
// Build target file path
|
||||
targetPath := filepath.Join(targetDir, new_path)
|
||||
|
||||
// Ensure target file's directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func createWorkspaceTemplates(workspace string) {
|
||||
err := copyEmbeddedToTarget(workspace)
|
||||
if err != nil {
|
||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||
}
|
||||
}
|
||||
305
cmd/picoclaw/cmd_skills.go
Normal file
305
cmd/picoclaw/cmd_skills.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func skillsHelp() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
fmt.Println(" list List installed skills")
|
||||
fmt.Println(" install <repo> Install skill from GitHub")
|
||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||
fmt.Println(" list-builtin List available builtin skills")
|
||||
fmt.Println(" remove <name> Remove installed skill")
|
||||
fmt.Println(" search Search available skills")
|
||||
fmt.Println(" show <name> Show skill details")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw skills list")
|
||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
fmt.Println(" picoclaw skills install-builtin")
|
||||
fmt.Println(" picoclaw skills list-builtin")
|
||||
fmt.Println(" picoclaw skills remove weather")
|
||||
fmt.Println(" picoclaw skills install --registry clawhub github")
|
||||
}
|
||||
|
||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
if len(allSkills) == 0 {
|
||||
fmt.Println("No skills installed.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nInstalled Skills:")
|
||||
fmt.Println("------------------")
|
||||
for _, skill := range allSkills {
|
||||
fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
|
||||
if skill.Description != "" {
|
||||
fmt.Printf(" %s\n", skill.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||
fmt.Println(" picoclaw skills install --registry <name> <slug>")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for --registry flag.
|
||||
if os.Args[3] == "--registry" {
|
||||
if len(os.Args) < 6 {
|
||||
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
|
||||
fmt.Println("Example: picoclaw skills install --registry clawhub github")
|
||||
return
|
||||
}
|
||||
registryName := os.Args[4]
|
||||
slug := os.Args[5]
|
||||
skillsInstallFromRegistry(cfg, registryName, slug)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: install from GitHub (backward compatible).
|
||||
repo := os.Args[3]
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
}
|
||||
|
||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
||||
err := utils.ValidateSkillIdentifier(registryName)
|
||||
if err != nil {
|
||||
fmt.Printf("\u2717 Invalid registry name: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = utils.ValidateSkillIdentifier(slug)
|
||||
if err != nil {
|
||||
fmt.Printf("\u2717 Invalid slug: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||
})
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
|
||||
if _, err = os.Stat(targetDir); err == nil {
|
||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||
if err != nil {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result.IsMalwareBlocked {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result.IsSuspicious {
|
||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
||||
if result.Summary != "" {
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||
}
|
||||
|
||||
func skillsInstallBuiltinCmd(workspace string) {
|
||||
builtinSkillsDir := "./picoclaw/skills"
|
||||
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
||||
|
||||
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||
|
||||
skillsToInstall := []string{
|
||||
"weather",
|
||||
"news",
|
||||
"stock",
|
||||
"calculator",
|
||||
}
|
||||
|
||||
for _, skillName := range skillsToInstall {
|
||||
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
||||
|
||||
if _, err := os.Stat(builtinPath); err != nil {
|
||||
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
|
||||
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyDirectory(builtinPath, workspacePath); err != nil {
|
||||
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ All builtin skills installed!")
|
||||
fmt.Println("Now you can use them in your workspace.")
|
||||
}
|
||||
|
||||
func skillsListBuiltinCmd() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
|
||||
|
||||
fmt.Println("\nAvailable Builtin Skills:")
|
||||
fmt.Println("-----------------------")
|
||||
|
||||
entries, err := os.ReadDir(builtinSkillsDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading builtin skills: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No builtin skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
skillName := entry.Name()
|
||||
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
||||
|
||||
description := "No description"
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
data, err := os.ReadFile(skillFile)
|
||||
if err == nil {
|
||||
content := string(data)
|
||||
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||
firstLine := content[:idx]
|
||||
if strings.Contains(firstLine, "description:") {
|
||||
descLine := strings.Index(content[idx:], "\n")
|
||||
if descLine > 0 {
|
||||
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "✓"
|
||||
fmt.Printf(" %s %s\n", status, entry.Name())
|
||||
if description != "" {
|
||||
fmt.Printf(" %s\n", description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
||||
fmt.Println("Searching for available skills...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(availableSkills) == 0 {
|
||||
fmt.Println("No skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
||||
fmt.Println("--------------------")
|
||||
for _, skill := range availableSkills {
|
||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
||||
fmt.Printf(" %s\n", skill.Description)
|
||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
||||
if skill.Author != "" {
|
||||
fmt.Printf(" Author: %s\n", skill.Author)
|
||||
}
|
||||
if len(skill.Tags) > 0 {
|
||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
||||
content, ok := loader.LoadSkill(skillName)
|
||||
if !ok {
|
||||
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n📦 Skill: %s\n", skillName)
|
||||
fmt.Println("----------------------")
|
||||
fmt.Println(content)
|
||||
}
|
||||
102
cmd/picoclaw/cmd_status.go
Normal file
102
cmd/picoclaw/cmd_status.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
)
|
||||
|
||||
func statusCmd() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
configPath := getConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n", logo)
|
||||
fmt.Printf("Version: %s\n", formatVersion())
|
||||
build, _ := formatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf("Build: %s\n", build)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Println("Config:", configPath, "✓")
|
||||
} else {
|
||||
fmt.Println("Config:", configPath, "✗")
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
if _, err := os.Stat(workspace); err == nil {
|
||||
fmt.Println("Workspace:", workspace, "✓")
|
||||
} else {
|
||||
fmt.Println("Workspace:", workspace, "✗")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
||||
|
||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
||||
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
||||
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
||||
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
||||
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
||||
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
||||
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
||||
|
||||
status := func(enabled bool) string {
|
||||
if enabled {
|
||||
return "✓"
|
||||
}
|
||||
return "not set"
|
||||
}
|
||||
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
||||
fmt.Println("Anthropic API:", status(hasAnthropic))
|
||||
fmt.Println("OpenAI API:", status(hasOpenAI))
|
||||
fmt.Println("Gemini API:", status(hasGemini))
|
||||
fmt.Println("Zhipu API:", status(hasZhipu))
|
||||
fmt.Println("Qwen API:", status(hasQwen))
|
||||
fmt.Println("Groq API:", status(hasGroq))
|
||||
fmt.Println("Moonshot API:", status(hasMoonshot))
|
||||
fmt.Println("DeepSeek API:", status(hasDeepSeek))
|
||||
fmt.Println("VolcEngine API:", status(hasVolcEngine))
|
||||
fmt.Println("Nvidia API:", status(hasNvidia))
|
||||
if hasVLLM {
|
||||
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
||||
} else {
|
||||
fmt.Println("vLLM/Local: not set")
|
||||
}
|
||||
if hasOllama {
|
||||
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
|
||||
} else {
|
||||
fmt.Println("Ollama: not set")
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "authenticated"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
}
|
||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1229
cmd/picoclaw/main.go
1229
cmd/picoclaw/main.go
File diff suppressed because it is too large
Load diff
|
|
@ -3,22 +3,67 @@
|
|||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model": "glm-4.7",
|
||||
"model": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key",
|
||||
"api_base": "https://api.anthropic.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini",
|
||||
"model": "antigravity/gemini-2.0-flash",
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
{
|
||||
"model_name": "deepseek",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-key1",
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "loadbalanced-gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-key2",
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"proxy": "",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
"allow_from": [
|
||||
"YOUR_USER_ID"
|
||||
]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
"allow_from": [],
|
||||
"mention_only": false
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "YOUR_QQ_APP_ID",
|
||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||
"allow_from": []
|
||||
},
|
||||
"maixcam": {
|
||||
|
|
@ -68,16 +113,44 @@
|
|||
"reconnect_interval": 5,
|
||||
"group_trigger_prefix": [],
|
||||
"allow_from": []
|
||||
},
|
||||
"wecom": {
|
||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||
"enabled": false,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18793,
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
},
|
||||
"wecom_app": {
|
||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||
"enabled": false,
|
||||
"corp_id": "YOUR_CORP_ID",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18792,
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": [],
|
||||
"reply_timeout": 5
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
|
||||
"anthropic": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
"api_base": "",
|
||||
"web_search": true
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx",
|
||||
|
|
@ -108,16 +181,56 @@
|
|||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"qwen": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
},
|
||||
"cerebras": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"volcengine": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
},
|
||||
"exec": {
|
||||
"enable_deny_patterns": false,
|
||||
"custom_deny_patterns": []
|
||||
},
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"enabled": true,
|
||||
"base_url": "https://clawhub.ai",
|
||||
"search_path": "/api/v1/search",
|
||||
"skills_path": "/api/v1/skills",
|
||||
"download_path": "/api/v1/download"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ services:
|
|||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
- agent
|
||||
# Uncomment to access host network; leave commented unless needed.
|
||||
#extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||
entrypoint: ["picoclaw", "agent"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
|
@ -29,11 +32,14 @@ services:
|
|||
restart: unless-stopped
|
||||
profiles:
|
||||
- gateway
|
||||
# Uncomment to access host network; leave commented unless needed.
|
||||
#extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Configuration file
|
||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
# Persistent workspace (sessions, memory, logs)
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||
command: ["gateway"]
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
807
docs/ANTIGRAVITY_AUTH.md
Normal file
807
docs/ANTIGRAVITY_AUTH.md
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
# Antigravity Authentication & Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
**Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Authentication Flow](#authentication-flow)
|
||||
2. [OAuth Implementation Details](#oauth-implementation-details)
|
||||
3. [Token Management](#token-management)
|
||||
4. [Models List Fetching](#models-list-fetching)
|
||||
5. [Usage Tracking](#usage-tracking)
|
||||
6. [Provider Plugin Structure](#provider-plugin-structure)
|
||||
7. [Integration Requirements](#integration-requirements)
|
||||
8. [API Endpoints](#api-endpoints)
|
||||
9. [Configuration](#configuration)
|
||||
10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### 1. OAuth 2.0 with PKCE
|
||||
|
||||
Antigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐
|
||||
│ Client │ ───(1) Generate PKCE Pair────────> │ │
|
||||
│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
|
||||
│ │ │ Server │
|
||||
│ │ <──(3) Redirect with Code───────── │ │
|
||||
│ │ └─────────────────┘
|
||||
│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
|
||||
│ │ │ │
|
||||
│ │ <──(5) Access + Refresh Tokens──── │ │
|
||||
└─────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 2. Detailed Steps
|
||||
|
||||
#### Step 1: Generate PKCE Parameters
|
||||
```typescript
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Build Authorization URL
|
||||
```typescript
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
|
||||
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
||||
const url = new URL(AUTH_URL);
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("code_challenge", params.challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
return url.toString();
|
||||
}
|
||||
```
|
||||
|
||||
**Required Scopes:**
|
||||
```typescript
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
```
|
||||
|
||||
#### Step 3: Handle OAuth Callback
|
||||
|
||||
**Automatic Mode (Local Development):**
|
||||
- Start a local HTTP server on port 51121
|
||||
- Wait for the redirect from Google
|
||||
- Extract the authorization code from the query parameters
|
||||
|
||||
**Manual Mode (Remote/Headless):**
|
||||
- Display the authorization URL to the user
|
||||
- User completes authentication in their browser
|
||||
- User pastes the full redirect URL back into the terminal
|
||||
- Parse the code from the pasted URL
|
||||
|
||||
#### Step 4: Exchange Code for Tokens
|
||||
```typescript
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
|
||||
async function exchangeCode(params: {
|
||||
code: string;
|
||||
verifier: string;
|
||||
}): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: params.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
access: data.access_token,
|
||||
refresh: data.refresh_token,
|
||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 5: Fetch Additional User Data
|
||||
|
||||
**User Email:**
|
||||
```typescript
|
||||
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
const response = await fetch(
|
||||
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.email;
|
||||
}
|
||||
```
|
||||
|
||||
**Project ID (Required for API calls):**
|
||||
```typescript
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.cloudaicompanionProject || "rising-fact-p41fc"; // Default fallback
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OAuth Implementation Details
|
||||
|
||||
### Client Credentials
|
||||
|
||||
**Important:** These are base64-encoded in the source code for sync with pi-ai:
|
||||
|
||||
```typescript
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
|
||||
);
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
```
|
||||
|
||||
### OAuth Flow Modes
|
||||
|
||||
1. **Automatic Flow** (Local machines with browser):
|
||||
- Opens browser automatically
|
||||
- Local callback server captures redirect
|
||||
- No user interaction required after initial auth
|
||||
|
||||
2. **Manual Flow** (Remote/headless/WSL2):
|
||||
- URL displayed for manual copy-paste
|
||||
- User completes auth in external browser
|
||||
- User pastes full redirect URL back
|
||||
|
||||
```typescript
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2Sync();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token Management
|
||||
|
||||
### Auth Profile Structure
|
||||
|
||||
```typescript
|
||||
type OAuthCredential = {
|
||||
type: "oauth";
|
||||
provider: "google-antigravity";
|
||||
access: string; // Access token
|
||||
refresh: string; // Refresh token
|
||||
expires: number; // Expiration timestamp (ms since epoch)
|
||||
email?: string; // User email
|
||||
projectId?: string; // Google Cloud project ID
|
||||
};
|
||||
```
|
||||
|
||||
### Token Refresh
|
||||
|
||||
The credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Models List Fetching
|
||||
|
||||
### Fetch Available Models
|
||||
|
||||
```typescript
|
||||
const BASE_URL = "https://cloudcode-pa.googleapis.com";
|
||||
|
||||
async function fetchAvailableModels(
|
||||
accessToken: string,
|
||||
projectId: string
|
||||
): Promise<Model[]> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/v1internal:fetchAvailableModels`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ project: projectId }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Returns models with quota information
|
||||
return Object.entries(data.models).map(([modelId, modelInfo]) => ({
|
||||
id: modelId,
|
||||
displayName: modelInfo.displayName,
|
||||
quotaInfo: {
|
||||
remainingFraction: modelInfo.quotaInfo?.remainingFraction,
|
||||
resetTime: modelInfo.quotaInfo?.resetTime,
|
||||
isExhausted: modelInfo.quotaInfo?.isExhausted,
|
||||
},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```typescript
|
||||
type FetchAvailableModelsResponse = {
|
||||
models?: Record<string, {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number | string;
|
||||
resetTime?: string; // ISO 8601 timestamp
|
||||
isExhausted?: boolean;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Tracking
|
||||
|
||||
### Fetch Usage Data
|
||||
|
||||
```typescript
|
||||
export async function fetchAntigravityUsage(
|
||||
token: string,
|
||||
timeoutMs: number
|
||||
): Promise<ProviderUsageSnapshot> {
|
||||
// 1. Fetch credits and plan info
|
||||
const loadCodeAssistRes = await fetch(
|
||||
`${BASE_URL}/v1internal:loadCodeAssist`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "ANTIGRAVITY",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// Extract credits info
|
||||
const { availablePromptCredits, planInfo, currentTier } = data;
|
||||
|
||||
// 2. Fetch model quotas
|
||||
const modelsRes = await fetch(
|
||||
`${BASE_URL}/v1internal:fetchAvailableModels`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ project: projectId }),
|
||||
}
|
||||
);
|
||||
|
||||
// Build usage windows
|
||||
return {
|
||||
provider: "google-antigravity",
|
||||
displayName: "Google Antigravity",
|
||||
windows: [
|
||||
{ label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
|
||||
// Individual model quotas...
|
||||
],
|
||||
plan: currentTier?.name || planType,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Response Structure
|
||||
|
||||
```typescript
|
||||
type ProviderUsageSnapshot = {
|
||||
provider: "google-antigravity";
|
||||
displayName: string;
|
||||
windows: UsageWindow[];
|
||||
plan?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type UsageWindow = {
|
||||
label: string; // "Credits" or model ID
|
||||
usedPercent: number; // 0-100
|
||||
resetAt?: number; // Timestamp when quota resets
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Plugin Structure
|
||||
|
||||
### Plugin Definition
|
||||
|
||||
```typescript
|
||||
const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
|
||||
register(api: PicoClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["antigravity"],
|
||||
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx: ProviderAuthContext) => {
|
||||
// OAuth implementation here
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ProviderAuthContext
|
||||
|
||||
```typescript
|
||||
type ProviderAuthContext = {
|
||||
config: PicoClawConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
prompter: WizardPrompter; // UI prompts/notifications
|
||||
runtime: RuntimeEnv; // Logging, etc.
|
||||
isRemote: boolean; // Whether running remotely
|
||||
openUrl: (url: string) => Promise<void>; // Browser opener
|
||||
oauth: {
|
||||
createVpsAwareHandlers: Function;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ProviderAuthResult
|
||||
|
||||
```typescript
|
||||
type ProviderAuthResult = {
|
||||
profiles: Array<{
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
}>;
|
||||
configPatch?: Partial<PicoClawConfig>;
|
||||
defaultModel?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Requirements
|
||||
|
||||
### 1. Required Environment/Dependencies
|
||||
|
||||
- Go ≥ 1.21
|
||||
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
|
||||
- `crypto` and `net/http` standard library packages
|
||||
|
||||
### 2. Required Headers for API Calls
|
||||
|
||||
```typescript
|
||||
const REQUIRED_HEADERS = {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity", // or "google-api-nodejs-client/9.15.1"
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
};
|
||||
|
||||
// For loadCodeAssist calls, also include:
|
||||
const CLIENT_METADATA = {
|
||||
ideType: "ANTIGRAVITY", // or "IDE_UNSPECIFIED"
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Model Schema Sanitization
|
||||
|
||||
Antigravity uses Gemini-compatible models, so tool schemas must be sanitized:
|
||||
|
||||
```typescript
|
||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
// Clean schema before sending
|
||||
function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
|
||||
// Remove unsupported keywords
|
||||
// Ensure top-level has type: "object"
|
||||
// Flatten anyOf/oneOf unions
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Thinking Block Handling (Claude Models)
|
||||
|
||||
For Antigravity Claude models, thinking blocks require special handling:
|
||||
|
||||
```typescript
|
||||
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
export function sanitizeAntigravityThinkingBlocks(
|
||||
messages: AgentMessage[]
|
||||
): AgentMessage[] {
|
||||
// Validate thinking signatures
|
||||
// Normalize signature fields
|
||||
// Discard unsigned thinking blocks
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization |
|
||||
| `https://oauth2.googleapis.com/token` | POST | Token exchange |
|
||||
| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) |
|
||||
|
||||
### Cloud Code Assist Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan |
|
||||
| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas |
|
||||
| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint |
|
||||
|
||||
**API Request Format (Chat):**
|
||||
The `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "your-project-id",
|
||||
"model": "model-id",
|
||||
"request": {
|
||||
"contents": [...],
|
||||
"systemInstruction": {...},
|
||||
"generationConfig": {...},
|
||||
"tools": [...]
|
||||
},
|
||||
"requestType": "agent",
|
||||
"userAgent": "antigravity",
|
||||
"requestId": "agent-timestamp-random"
|
||||
}
|
||||
```
|
||||
|
||||
**API Response Format (SSE):**
|
||||
Each SSE message (`data: {...}`) is wrapped in a `response` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"response": {
|
||||
"candidates": [...],
|
||||
"usageMetadata": {...},
|
||||
"modelVersion": "...",
|
||||
"responseId": "..."
|
||||
},
|
||||
"traceId": "...",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### config.json Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gemini-flash",
|
||||
"model": "antigravity/gemini-3-flash",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gemini-flash"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Profile Storage
|
||||
|
||||
Auth profiles are stored in `~/.picoclaw/auth.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"google-antigravity": {
|
||||
"access_token": "ya29...",
|
||||
"refresh_token": "1//...",
|
||||
"expires_at": "2026-01-01T00:00:00Z",
|
||||
"provider": "google-antigravity",
|
||||
"auth_method": "oauth",
|
||||
"email": "user@example.com",
|
||||
"project_id": "my-project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Provider in PicoClaw
|
||||
|
||||
PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:
|
||||
|
||||
### Step-by-Step Implementation
|
||||
|
||||
#### 1. Create Provider File
|
||||
|
||||
Create a new Go file in `pkg/providers/`:
|
||||
|
||||
```
|
||||
pkg/providers/
|
||||
└── your_provider.go
|
||||
```
|
||||
|
||||
#### 2. Implement the Provider Interface
|
||||
|
||||
Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`:
|
||||
|
||||
```go
|
||||
package providers
|
||||
|
||||
type YourProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
}
|
||||
|
||||
func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.your-provider.com/v1"
|
||||
}
|
||||
return &YourProvider{apiKey: apiKey, apiBase: apiBase}
|
||||
}
|
||||
|
||||
func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
|
||||
// Implement chat completion with streaming
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Register in the Factory
|
||||
|
||||
Add your provider to the protocol switch in `pkg/providers/factory.go`:
|
||||
|
||||
```go
|
||||
case "your-provider":
|
||||
return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
|
||||
```
|
||||
|
||||
#### 4. Add Default Config (Optional)
|
||||
|
||||
Add a default entry in `pkg/config/defaults.go`:
|
||||
|
||||
```go
|
||||
{
|
||||
ModelName: "your-model",
|
||||
Model: "your-provider/model-name",
|
||||
APIKey: "",
|
||||
},
|
||||
```
|
||||
|
||||
#### 5. Add Auth Support (Optional)
|
||||
|
||||
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
|
||||
|
||||
```go
|
||||
case "your-provider":
|
||||
authLoginYourProvider()
|
||||
```
|
||||
|
||||
#### 6. Configure via `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "your-model",
|
||||
"model": "your-provider/model-name",
|
||||
"api_key": "your-api-key",
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Your Implementation
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Authenticate with a provider
|
||||
picoclaw auth login --provider your-provider
|
||||
|
||||
# List models (for Antigravity)
|
||||
picoclaw auth models
|
||||
|
||||
# Start the gateway
|
||||
picoclaw gateway
|
||||
|
||||
# Run an agent with a specific model
|
||||
picoclaw agent -m "Hello" --model your-model
|
||||
```
|
||||
|
||||
### Environment Variables for Testing
|
||||
|
||||
```bash
|
||||
# Override default model
|
||||
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
|
||||
|
||||
# Override provider settings
|
||||
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Source Files:**
|
||||
- `pkg/providers/antigravity_provider.go` - Antigravity provider implementation
|
||||
- `pkg/auth/oauth.go` - OAuth flow implementation
|
||||
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
|
||||
- `pkg/providers/factory.go` - Provider factory and protocol routing
|
||||
- `pkg/providers/types.go` - Provider interface definitions
|
||||
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
|
||||
|
||||
- **Documentation:**
|
||||
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
|
||||
- `docs/migration/model-list-migration.md` - Migration guide
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project
|
||||
2. **Quotas:** Uses Google Cloud project quotas (not separate billing)
|
||||
3. **Model Access:** Available models depend on your Google Cloud project configuration
|
||||
4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures
|
||||
5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Common Error Handling
|
||||
|
||||
### 1. Rate Limiting (HTTP 429)
|
||||
|
||||
Antigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field.
|
||||
|
||||
**Example 429 Error:**
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"metadata": {
|
||||
"quotaResetDelay": "4h30m28.060903746s"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Empty Responses (Restricted Models)
|
||||
|
||||
Some models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use.
|
||||
|
||||
**Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Token expired"
|
||||
- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`
|
||||
|
||||
### "Gemini for Google Cloud is not enabled"
|
||||
- Enable the API in your Google Cloud Console
|
||||
|
||||
### "Project not found"
|
||||
- Ensure your Google Cloud project has the necessary APIs enabled
|
||||
- Check that the project ID is correctly fetched during authentication
|
||||
|
||||
### Models not appearing in list
|
||||
- Verify OAuth authentication completed successfully
|
||||
- Check auth profile storage: `~/.picoclaw/auth.json`
|
||||
- Re-run `picoclaw auth login --provider antigravity`
|
||||
70
docs/ANTIGRAVITY_USAGE.md
Normal file
70
docs/ANTIGRAVITY_USAGE.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Using Antigravity Provider in PicoClaw
|
||||
|
||||
This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Google account.
|
||||
2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
To authenticate with Antigravity, run the following command:
|
||||
|
||||
```bash
|
||||
picoclaw auth login --provider antigravity
|
||||
```
|
||||
|
||||
### Manual Authentication (Headless/VPS)
|
||||
If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
|
||||
1. Run the command above.
|
||||
2. Copy the URL provided and open it in your local browser.
|
||||
3. Complete the login.
|
||||
4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
|
||||
5. **Copy that final URL** from your browser's address bar.
|
||||
6. **Paste it back into the terminal** where PicoClaw is waiting.
|
||||
|
||||
PicoClaw will extract the authorization code and complete the process automatically.
|
||||
|
||||
## 2. Managing Models
|
||||
|
||||
### List Available Models
|
||||
To see which models your project has access to and check their quotas:
|
||||
|
||||
```bash
|
||||
picoclaw auth models
|
||||
```
|
||||
|
||||
### Switch Models
|
||||
You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
|
||||
|
||||
```bash
|
||||
# Override for a single command
|
||||
picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
|
||||
```
|
||||
|
||||
## 3. Real-world Usage (Coolify/Docker)
|
||||
|
||||
If you are deploying via Coolify or Docker, follow these steps to test:
|
||||
|
||||
1. **Environment Variables**:
|
||||
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
|
||||
2. **Authentication persistence**:
|
||||
If you've logged in locally, you can copy your credentials to the server:
|
||||
```bash
|
||||
scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
|
||||
```
|
||||
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
|
||||
* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
|
||||
* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
|
||||
|
||||
## 5. Summary of Working Models
|
||||
|
||||
Based on testing, the following models are most reliable:
|
||||
* `gemini-3-flash` (Fast, highly available)
|
||||
* `gemini-2.5-flash-lite` (Lightweight)
|
||||
* `claude-opus-4-6-thinking` (Powerful, includes reasoning)
|
||||
174
docs/design/provider-refactoring-tests.md
Normal file
174
docs/design/provider-refactoring-tests.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Provider Architecture Refactoring - Test Suite Summary
|
||||
|
||||
This document summarizes the complete test suite designed for the Provider architecture refactoring.
|
||||
|
||||
## Test File Structure
|
||||
|
||||
```
|
||||
pkg/
|
||||
├── config/
|
||||
│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
|
||||
│ └── migration_test.go # US-003: Backward compatibility and migration tests
|
||||
├── providers/
|
||||
│ ├── factory_test.go # US-004, US-005: Provider factory tests
|
||||
│ └── factory_provider_test.go # Factory provider integration tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Case Checklist
|
||||
|
||||
### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests
|
||||
|
||||
| Test Name | Purpose | PRD Reference |
|
||||
|-----------|---------|---------------|
|
||||
| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |
|
||||
| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |
|
||||
| `TestModelConfig_Validation` | Verify required field validation | US-001 |
|
||||
| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |
|
||||
| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |
|
||||
| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |
|
||||
| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |
|
||||
| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |
|
||||
| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |
|
||||
| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |
|
||||
|
||||
### 2. `pkg/config/migration_test.go` - Migration Tests
|
||||
|
||||
| Test Name | Purpose | PRD Reference |
|
||||
|-----------|---------|---------------|
|
||||
| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |
|
||||
| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |
|
||||
| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |
|
||||
| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |
|
||||
| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |
|
||||
| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |
|
||||
| `TestGenerateModelName_*` | Model name generation | US-003 |
|
||||
| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |
|
||||
| `TestValidateMigration_*` | Migration validation | US-003 |
|
||||
| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |
|
||||
| `TestMigrateConfig_Actual` | Actual migration | US-003 |
|
||||
|
||||
### 3. `pkg/providers/registry_test.go` - Load Balancing Tests
|
||||
|
||||
| Test Name | Purpose | PRD Reference |
|
||||
|-----------|---------|---------------|
|
||||
| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |
|
||||
| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |
|
||||
| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |
|
||||
| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |
|
||||
| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |
|
||||
| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |
|
||||
| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |
|
||||
| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |
|
||||
| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |
|
||||
| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |
|
||||
|
||||
### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests
|
||||
|
||||
| Test Name | Purpose | PRD Reference |
|
||||
|-----------|---------|---------------|
|
||||
| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |
|
||||
| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |
|
||||
| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |
|
||||
| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |
|
||||
| `TestExtractProtocol` | Protocol prefix extraction | US-004 |
|
||||
| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |
|
||||
| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |
|
||||
| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |
|
||||
|
||||
### 5. `pkg/providers/integration_test.go` - E2E Integration Tests
|
||||
|
||||
| Test Name | Purpose | PRD Reference |
|
||||
|-----------|---------|---------------|
|
||||
| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |
|
||||
| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |
|
||||
| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |
|
||||
| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |
|
||||
| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |
|
||||
| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |
|
||||
| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |
|
||||
| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |
|
||||
| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |
|
||||
|
||||
### 6. Performance Tests
|
||||
|
||||
| Test Name | Purpose |
|
||||
|-----------|---------|
|
||||
| `BenchmarkCreateProviderFromConfig` | Provider creation performance |
|
||||
| `BenchmarkGetModelConfig` | Model lookup performance |
|
||||
| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./pkg/... -v
|
||||
|
||||
# Run with data race detection
|
||||
go test ./pkg/... -race
|
||||
|
||||
# Run specific package tests
|
||||
go test ./pkg/config -v
|
||||
go test ./pkg/providers -v
|
||||
|
||||
# Run E2E tests
|
||||
go test ./pkg/providers -run TestE2E -v
|
||||
|
||||
# Run performance tests
|
||||
go test ./pkg/providers -bench=. -benchmem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PRD Acceptance Criteria Mapping
|
||||
|
||||
| PRD Acceptance Criteria | Test Cases |
|
||||
|------------------------|------------|
|
||||
| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |
|
||||
| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |
|
||||
| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |
|
||||
| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |
|
||||
| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |
|
||||
| US-003: Existing tests pass | (existing test files unchanged) |
|
||||
| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |
|
||||
| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |
|
||||
| US-005: CreateProvider uses factory | `TestCreateProvider_*` |
|
||||
| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |
|
||||
| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. **Phase 1: Configuration Structure** (US-001, US-002)
|
||||
- Implement `ModelConfig` struct
|
||||
- Implement `GetModelConfig` method
|
||||
- Run `model_config_test.go`
|
||||
|
||||
2. **Phase 2: Protocol Factory** (US-004)
|
||||
- Implement `CreateProviderFromConfig`
|
||||
- Implement `ExtractProtocol`
|
||||
- Run `factory_test.go`
|
||||
|
||||
3. **Phase 3: Load Balancing** (US-006)
|
||||
- Implement `ModelRegistry`
|
||||
- Implement round-robin selection
|
||||
- Run `registry_test.go` (with `-race`)
|
||||
|
||||
4. **Phase 4: Backward Compatibility** (US-003, US-005)
|
||||
- Implement `ConvertProvidersToModelList`
|
||||
- Refactor `CreateProvider`
|
||||
- Run `migration_test.go`
|
||||
- Verify existing tests pass
|
||||
|
||||
5. **Phase 5: E2E Verification**
|
||||
- Run `integration_test.go`
|
||||
- Manual testing with `config.example.json`
|
||||
334
docs/design/provider-refactoring.md
Normal file
334
docs/design/provider-refactoring.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# Provider Architecture Refactoring Design
|
||||
|
||||
> Issue: #283
|
||||
> Discussion: #122
|
||||
> Branch: feat/refactor-provider-by-protocol
|
||||
|
||||
## 1. Current Problems
|
||||
|
||||
### 1.1 Configuration Structure Issues
|
||||
|
||||
**Current State**: Each Provider requires a predefined field in `ProvidersConfig`
|
||||
|
||||
```go
|
||||
type ProvidersConfig struct {
|
||||
Anthropic ProviderConfig `json:"anthropic"`
|
||||
OpenAI ProviderConfig `json:"openai"`
|
||||
DeepSeek ProviderConfig `json:"deepseek"`
|
||||
Qwen ProviderConfig `json:"qwen"`
|
||||
Cerebras ProviderConfig `json:"cerebras"`
|
||||
VolcEngine ProviderConfig `json:"volcengine"`
|
||||
// ... every new provider requires changes here
|
||||
}
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
- Adding a new Provider requires modifying Go code (struct definition)
|
||||
- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case
|
||||
- Most Providers are OpenAI-compatible, but code is duplicated
|
||||
|
||||
### 1.2 Code Bloat Trend
|
||||
|
||||
Recent PRs demonstrate this issue:
|
||||
|
||||
| PR | Provider | Code Changes |
|
||||
|----|----------|--------------|
|
||||
| #365 | Qwen | +17 lines to http_provider.go |
|
||||
| #333 | Cerebras | +17 lines to http_provider.go |
|
||||
| #368 | Volcengine | +18 lines to http_provider.go |
|
||||
|
||||
Each OpenAI-compatible Provider requires:
|
||||
1. Modify `config.go` to add configuration field
|
||||
2. Modify `http_provider.go` to add switch case
|
||||
3. Update documentation
|
||||
|
||||
### 1.3 Agent-Provider Coupling
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "deepseek", // need to know provider name
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Problem: Agent needs to know both `provider` and `model`, adding complexity.
|
||||
|
||||
---
|
||||
|
||||
## 2. New Approach: model_list
|
||||
|
||||
### 2.1 Core Principles
|
||||
|
||||
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
|
||||
|
||||
1. **Model-centric**: Users care about models, not providers
|
||||
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
|
||||
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
|
||||
|
||||
### 2.2 New Configuration Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "openai/deepseek-chat",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"api_key": "sk-xxx"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.2",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-xxx"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-xxx"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-3-flash",
|
||||
"model": "antigravity/gemini-3-flash",
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
{
|
||||
"model_name": "my-company-llm",
|
||||
"model": "openai/company-model-v1",
|
||||
"api_base": "https://llm.company.com/v1",
|
||||
"api_key": "xxx"
|
||||
}
|
||||
],
|
||||
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "deepseek-chat",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Go Struct Definition
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
ModelList []ModelConfig `json:"model_list"` // new
|
||||
Providers ProvidersConfig `json:"providers"` // old, deprecated
|
||||
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
// ...
|
||||
}
|
||||
|
||||
type ModelConfig struct {
|
||||
// Required
|
||||
ModelName string `json:"model_name"` // user-facing name (alias)
|
||||
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
|
||||
|
||||
// Common config
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
|
||||
// Special provider config
|
||||
AuthMethod string `json:"auth_method,omitempty"` // oauth, token
|
||||
ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc
|
||||
|
||||
// Optional optimizations
|
||||
RPM int `json:"rpm,omitempty"` // rate limit
|
||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Protocol Recognition
|
||||
|
||||
Identify protocol via prefix in `model` field:
|
||||
|
||||
| Prefix | Protocol | Description |
|
||||
|--------|----------|-------------|
|
||||
| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
|
||||
| `anthropic/` | Anthropic | Claude series specific |
|
||||
| `antigravity/` | Antigravity | Google Cloud Code Assist |
|
||||
| `gemini/` | Gemini | Google Gemini native API (if needed) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Design Rationale
|
||||
|
||||
### 3.1 Problems Solved
|
||||
|
||||
| Problem | Old Approach | New Approach |
|
||||
|---------|--------------|--------------|
|
||||
| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |
|
||||
| Agent specifies model | Need provider + model | Only need model |
|
||||
| Code duplication | Each Provider duplicates logic | Share protocol implementation |
|
||||
| Multi-Agent support | Complex | Naturally compatible |
|
||||
|
||||
### 3.2 Multi-Agent Compatibility
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [...],
|
||||
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "deepseek-chat"
|
||||
},
|
||||
"coder": {
|
||||
"model": "gpt-5.2",
|
||||
"system_prompt": "You are a coding assistant..."
|
||||
},
|
||||
"translator": {
|
||||
"model": "claude-sonnet-4.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).
|
||||
|
||||
### 3.3 Industry Comparison
|
||||
|
||||
**LiteLLM** (most mature open-source LLM Proxy) uses similar design:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-5.2
|
||||
api_key: xxx
|
||||
- model_name: my-custom
|
||||
litellm_params:
|
||||
model: openai/custom-model
|
||||
api_base: https://my-api.com/v1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration Plan
|
||||
|
||||
### 4.1 Phase 1: Compatibility Period (v1.x)
|
||||
|
||||
Support both `providers` and `model_list`:
|
||||
|
||||
```go
|
||||
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||
// Prefer new config
|
||||
if len(c.ModelList) > 0 {
|
||||
return c.findModelByName(modelName)
|
||||
}
|
||||
|
||||
// Backward compatibility with old config
|
||||
if !c.Providers.IsEmpty() {
|
||||
logger.Warn("'providers' config is deprecated, please migrate to 'model_list'")
|
||||
return c.convertFromProviders(modelName)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("model %s not found", modelName)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Phase 2: Warning Period (late v1.x)
|
||||
|
||||
- Print more prominent warnings at startup
|
||||
- Provide automatic migration script
|
||||
- Mark `providers` as deprecated in documentation
|
||||
|
||||
### 4.3 Phase 3: Removal Period (v2.0)
|
||||
|
||||
- Completely remove `providers` support
|
||||
- Remove `agents.defaults.provider` field
|
||||
- Only support `model_list`
|
||||
|
||||
### 4.4 Configuration Migration Example
|
||||
|
||||
**Old Config**:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"deepseek": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": "https://api.deepseek.com/v1"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Config**:
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "openai/deepseek-chat",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"api_key": "sk-xxx"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Checklist
|
||||
|
||||
### 5.1 Configuration Layer
|
||||
|
||||
- [ ] Add `ModelConfig` struct
|
||||
- [ ] Add `Config.ModelList` field
|
||||
- [ ] Implement `GetModelConfig(modelName)` method
|
||||
- [ ] Implement old config compatibility conversion
|
||||
- [ ] Add `model_name` uniqueness validation
|
||||
|
||||
### 5.2 Provider Layer
|
||||
|
||||
- [ ] Create `pkg/providers/factory/` directory
|
||||
- [ ] Implement `CreateProviderFromModelConfig()`
|
||||
- [ ] Refactor `http_provider.go` to `openai/provider.go`
|
||||
- [ ] Maintain backward compatibility for old `CreateProvider()`
|
||||
|
||||
### 5.3 Testing
|
||||
|
||||
- [ ] New config unit tests
|
||||
- [ ] Old config compatibility tests
|
||||
- [ ] Integration tests
|
||||
|
||||
### 5.4 Documentation
|
||||
|
||||
- [ ] Update README
|
||||
- [ ] Update config.example.json
|
||||
- [ ] Write migration guide
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Breaking existing configs | Compatibility period keeps old config working |
|
||||
| User migration cost | Provide automatic migration script |
|
||||
| Special Provider incompatibility | Keep `auth_method` and other extension fields |
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)
|
||||
- [One-API GitHub](https://github.com/songquanpeng/one-api)
|
||||
- Discussion #122: Refactor Provider Architecture
|
||||
219
docs/migration/model-list-migration.md
Normal file
219
docs/migration/model-list-migration.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# Migration Guide: From `providers` to `model_list`
|
||||
|
||||
This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.
|
||||
|
||||
## Why Migrate?
|
||||
|
||||
The new `model_list` configuration offers several advantages:
|
||||
|
||||
- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
|
||||
- **Load balancing**: Configure multiple endpoints for the same model
|
||||
- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
|
||||
- **Cleaner configuration**: Model-centric instead of vendor-centric
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Status |
|
||||
|---------|--------|
|
||||
| v1.x | `model_list` introduced, `providers` deprecated but functional |
|
||||
| v1.x+1 | Prominent deprecation warnings, migration tool available |
|
||||
| v2.0 | `providers` configuration removed |
|
||||
|
||||
## Before and After
|
||||
|
||||
### Before: Legacy `providers` Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
"deepseek": {
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: New `model_list` Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-your-key"
|
||||
},
|
||||
{
|
||||
"model_name": "deepseek",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Prefixes
|
||||
|
||||
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||
|
||||
| Prefix | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
|
||||
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
|
||||
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
|
||||
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
|
||||
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
|
||||
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
|
||||
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
|
||||
| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
|
||||
| `groq/` | Groq API | `groq/llama-3.1-70b` |
|
||||
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
|
||||
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
|
||||
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
|
||||
| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
|
||||
| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
|
||||
| `ollama/` | Ollama (local) | `ollama/llama3` |
|
||||
| `vllm/` | vLLM (local) | `vllm/my-model` |
|
||||
| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
|
||||
| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
|
||||
| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
|
||||
|
||||
**Note**: If no prefix is specified, `openai/` is used as the default.
|
||||
|
||||
## ModelConfig Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `model_name` | Yes | User-facing alias for the model |
|
||||
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
|
||||
| `api_base` | No | API endpoint URL |
|
||||
| `api_key` | No* | API authentication key |
|
||||
| `proxy` | No | HTTP proxy URL |
|
||||
| `auth_method` | No | Authentication method: `oauth`, `token` |
|
||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||
| `rpm` | No | Requests per minute limit |
|
||||
| `max_tokens_field` | No | Field name for max tokens |
|
||||
|
||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||
|
||||
## Load Balancing
|
||||
|
||||
Configure multiple endpoints for the same model to distribute load:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-key1",
|
||||
"api_base": "https://api1.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-key2",
|
||||
"api_base": "https://api2.example.com/v1"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "sk-key3",
|
||||
"api_base": "https://api3.example.com/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.
|
||||
|
||||
## Adding a New OpenAI-Compatible Provider
|
||||
|
||||
With `model_list`, adding a new provider requires zero code changes:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "my-custom-llm",
|
||||
"model": "openai/my-model-v1",
|
||||
"api_key": "your-api-key",
|
||||
"api_base": "https://api.your-provider.com/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
During the migration period, your existing `providers` configuration will continue to work:
|
||||
|
||||
1. If `model_list` is empty and `providers` has data, the system auto-converts internally
|
||||
2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
|
||||
3. All existing functionality remains unchanged
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Identify all providers you're currently using
|
||||
- [ ] Create `model_list` entries for each provider
|
||||
- [ ] Use appropriate protocol prefixes
|
||||
- [ ] Update `agents.defaults.model` to reference the new `model_name`
|
||||
- [ ] Test that all models work correctly
|
||||
- [ ] Remove or comment out the old `providers` section
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Model not found error
|
||||
|
||||
```
|
||||
model "xxx" not found in model_list or providers
|
||||
```
|
||||
|
||||
**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
|
||||
|
||||
### Unknown protocol error
|
||||
|
||||
```
|
||||
unknown protocol "xxx" in model "xxx/model-name"
|
||||
```
|
||||
|
||||
**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
|
||||
|
||||
### Missing API key error
|
||||
|
||||
```
|
||||
api_key or api_base is required for HTTP-based protocol "xxx"
|
||||
```
|
||||
|
||||
**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)
|
||||
- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal
|
||||
|
|
@ -71,14 +71,14 @@ Interested in a specific feature? You can "claim" these tasks and start building
|
|||
* Support for OneBot, additional platforms
|
||||
* attachments (images, audio, video, files).
|
||||
* **Skills:**
|
||||
* Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
|
||||
* Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
|
||||
* **Operations:** * MCP Support.
|
||||
* Android operations (e.g., botdrop).
|
||||
* Browser automation via CDP or ActionBook.
|
||||
|
||||
|
||||
* **Multi-Agent Ecosystem:**
|
||||
* **Basic Model-Agnet** S
|
||||
* **Basic Model-Agent**
|
||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
||||
* **Swarm Mode.**
|
||||
* **AIEOS Integration.**
|
||||
143
docs/tools_configuration.md
Normal file
143
docs/tools_configuration.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Tools Configuration
|
||||
|
||||
PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": { ... },
|
||||
"exec": { ... },
|
||||
"cron": { ... },
|
||||
"skills": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Web Tools
|
||||
|
||||
Web tools are used for web search and fetching.
|
||||
|
||||
### Brave
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | bool | false | Enable Brave search |
|
||||
| `api_key` | string | - | Brave Search API key |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### DuckDuckGo
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Perplexity
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | bool | false | Enable Perplexity search |
|
||||
| `api_key` | string | - | Perplexity API key |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
## Exec Tool
|
||||
|
||||
The exec tool is used to execute shell commands.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||
|
||||
### Functionality
|
||||
|
||||
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
|
||||
- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
|
||||
|
||||
### Default Blocked Command Patterns
|
||||
|
||||
By default, PicoClaw blocks the following dangerous commands:
|
||||
|
||||
- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`
|
||||
- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`
|
||||
- System operations: `shutdown`, `reboot`, `poweroff`
|
||||
- Command substitution: `$()`, `${}`, backticks
|
||||
- Pipe to shell: `| sh`, `| bash`
|
||||
- Privilege escalation: `sudo`, `chmod`, `chown`
|
||||
- Process control: `pkill`, `killall`, `kill -9`
|
||||
- Remote operations: `curl | sh`, `wget | sh`, `ssh`
|
||||
- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`
|
||||
- Containers: `docker run`, `docker exec`
|
||||
- Git: `git push`, `git force`
|
||||
- Other: `eval`, `source *.sh`
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"exec": {
|
||||
"enable_deny_patterns": true,
|
||||
"custom_deny_patterns": [
|
||||
"\\brm\\s+-r\\b",
|
||||
"\\bkillall\\s+python"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Tool
|
||||
|
||||
The cron tool is used for scheduling periodic tasks.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||
|
||||
## Skills Tool
|
||||
|
||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||
|
||||
### Registries
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search 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
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"enabled": true,
|
||||
"base_url": "https://clawhub.ai",
|
||||
"search_path": "/api/v1/search",
|
||||
"skills_path": "/api/v1/skills",
|
||||
"download_path": "/api/v1/download"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
|
||||
|
||||
For example:
|
||||
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
|
||||
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
|
||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||
|
||||
Note: Array-type environment variables are not currently supported and must be set via the config file.
|
||||
117
docs/wecom-app-configuration.md
Normal file
117
docs/wecom-app-configuration.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# 企业微信自建应用 (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)
|
||||
|
|
@ -80,7 +80,7 @@ Your workspace is at: %s
|
|||
|
||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||
|
||||
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
|||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
|
||||
sb.WriteString(
|
||||
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
|
||||
)
|
||||
sb.WriteString("You have access to the following tools:\n\n")
|
||||
for _, s := range summaries {
|
||||
sb.WriteString(s)
|
||||
|
|
@ -146,18 +148,24 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|||
"IDENTITY.md",
|
||||
}
|
||||
|
||||
var result string
|
||||
var sb strings.Builder
|
||||
for _, filename := range bootstrapFiles {
|
||||
filePath := filepath.Join(cb.workspace, filename)
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
|
||||
func (cb *ContextBuilder) BuildMessages(
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
currentMessage string,
|
||||
media []string,
|
||||
channel, chatID string,
|
||||
) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
systemPrompt := cb.BuildSystemPrompt()
|
||||
|
|
@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
|
||||
// Log system prompt summary for debugging (debug mode only)
|
||||
logger.DebugCF("agent", "System prompt built",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"total_chars": len(systemPrompt),
|
||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
||||
|
|
@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
preview = preview[:500] + "... (truncated)"
|
||||
}
|
||||
logger.DebugCF("agent", "System prompt preview",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"preview": preview,
|
||||
})
|
||||
|
||||
|
|
@ -189,16 +197,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
||||
}
|
||||
|
||||
//This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
|
||||
// --- INICIO DEL FIX ---
|
||||
//Diegox-17
|
||||
for len(history) > 0 && (history[0].Role == "tool") {
|
||||
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
|
||||
map[string]interface{}{"role": history[0].Role})
|
||||
history = history[1:]
|
||||
}
|
||||
//Diegox-17
|
||||
// --- FIN DEL FIX ---
|
||||
history = sanitizeHistoryForProvider(history)
|
||||
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
|
|
@ -207,15 +206,66 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
|
|||
|
||||
messages = append(messages, history...)
|
||||
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "user",
|
||||
Content: currentMessage,
|
||||
})
|
||||
if strings.TrimSpace(currentMessage) != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "user",
|
||||
Content: currentMessage,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
|
||||
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
|
||||
if len(history) == 0 {
|
||||
return history
|
||||
}
|
||||
|
||||
sanitized := make([]providers.Message, 0, len(history))
|
||||
for _, msg := range history {
|
||||
switch msg.Role {
|
||||
case "tool":
|
||||
if len(sanitized) == 0 {
|
||||
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
|
||||
continue
|
||||
}
|
||||
last := sanitized[len(sanitized)-1]
|
||||
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
|
||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
||||
continue
|
||||
}
|
||||
sanitized = append(sanitized, msg)
|
||||
|
||||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
if len(sanitized) == 0 {
|
||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
||||
continue
|
||||
}
|
||||
prev := sanitized[len(sanitized)-1]
|
||||
if prev.Role != "user" && prev.Role != "tool" {
|
||||
logger.DebugCF(
|
||||
"agent",
|
||||
"Dropping assistant tool-call turn with invalid predecessor",
|
||||
map[string]any{"prev_role": prev.Role},
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sanitized = append(sanitized, msg)
|
||||
|
||||
default:
|
||||
sanitized = append(sanitized, msg)
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) AddToolResult(
|
||||
messages []providers.Message,
|
||||
toolCallID, toolName, result string,
|
||||
) []providers.Message {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
|
|
@ -224,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
|
|||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
|
||||
func (cb *ContextBuilder) AddAssistantMessage(
|
||||
messages []providers.Message,
|
||||
content string,
|
||||
toolCalls []map[string]any,
|
||||
) []providers.Message {
|
||||
msg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
|
|
@ -254,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string {
|
|||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
skillNames := make([]string, 0, len(allSkills))
|
||||
for _, s := range allSkills {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"total": len(allSkills),
|
||||
"available": len(allSkills),
|
||||
"names": skillNames,
|
||||
|
|
|
|||
159
pkg/agent/instance.go
Normal file
159
pkg/agent/instance.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// AgentInstance represents a fully configured agent with its own workspace,
|
||||
// session manager, context builder, and tool registry.
|
||||
type AgentInstance struct {
|
||||
ID string
|
||||
Name string
|
||||
Model string
|
||||
Fallbacks []string
|
||||
Workspace string
|
||||
MaxIterations int
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
ContextWindow int
|
||||
Provider providers.LLMProvider
|
||||
Sessions *session.SessionManager
|
||||
ContextBuilder *ContextBuilder
|
||||
Tools *tools.ToolRegistry
|
||||
Subagents *config.SubagentsConfig
|
||||
SkillsFilter []string
|
||||
Candidates []providers.FallbackCandidate
|
||||
}
|
||||
|
||||
// NewAgentInstance creates an agent instance from config.
|
||||
func NewAgentInstance(
|
||||
agentCfg *config.AgentConfig,
|
||||
defaults *config.AgentDefaults,
|
||||
cfg *config.Config,
|
||||
provider providers.LLMProvider,
|
||||
) *AgentInstance {
|
||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||
os.MkdirAll(workspace, 0o755)
|
||||
|
||||
model := resolveAgentModel(agentCfg, defaults)
|
||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||
|
||||
restrict := defaults.RestrictToWorkspace
|
||||
toolsRegistry := tools.NewToolRegistry()
|
||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
||||
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
||||
|
||||
sessionsDir := filepath.Join(workspace, "sessions")
|
||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||
|
||||
contextBuilder := NewContextBuilder(workspace)
|
||||
contextBuilder.SetToolsRegistry(toolsRegistry)
|
||||
|
||||
agentID := routing.DefaultAgentID
|
||||
agentName := ""
|
||||
var subagents *config.SubagentsConfig
|
||||
var skillsFilter []string
|
||||
|
||||
if agentCfg != nil {
|
||||
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||
agentName = agentCfg.Name
|
||||
subagents = agentCfg.Subagents
|
||||
skillsFilter = agentCfg.Skills
|
||||
}
|
||||
|
||||
maxIter := defaults.MaxToolIterations
|
||||
if maxIter == 0 {
|
||||
maxIter = 20
|
||||
}
|
||||
|
||||
maxTokens := defaults.MaxTokens
|
||||
if maxTokens == 0 {
|
||||
maxTokens = 8192
|
||||
}
|
||||
|
||||
temperature := 0.7
|
||||
if defaults.Temperature != nil {
|
||||
temperature = *defaults.Temperature
|
||||
}
|
||||
|
||||
// Resolve fallback candidates
|
||||
modelCfg := providers.ModelConfig{
|
||||
Primary: model,
|
||||
Fallbacks: fallbacks,
|
||||
}
|
||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
||||
|
||||
return &AgentInstance{
|
||||
ID: agentID,
|
||||
Name: agentName,
|
||||
Model: model,
|
||||
Fallbacks: fallbacks,
|
||||
Workspace: workspace,
|
||||
MaxIterations: maxIter,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
ContextWindow: maxTokens,
|
||||
Provider: provider,
|
||||
Sessions: sessionsManager,
|
||||
ContextBuilder: contextBuilder,
|
||||
Tools: toolsRegistry,
|
||||
Subagents: subagents,
|
||||
SkillsFilter: skillsFilter,
|
||||
Candidates: candidates,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||
}
|
||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||
return expandHome(defaults.Workspace)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
||||
}
|
||||
|
||||
// resolveAgentModel resolves the primary model for an agent.
|
||||
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||
}
|
||||
return defaults.Model
|
||||
}
|
||||
|
||||
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
|
||||
return agentCfg.Model.Fallbacks
|
||||
}
|
||||
return defaults.ModelFallbacks
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if path[0] == '~' {
|
||||
home, _ := os.UserHomeDir()
|
||||
if len(path) > 1 && path[1] == '/' {
|
||||
return home + path[1:]
|
||||
}
|
||||
return home
|
||||
}
|
||||
return path
|
||||
}
|
||||
95
pkg/agent/instance_test.go
Normal file
95
pkg/agent/instance_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(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: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configuredTemp := 1.0
|
||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||
|
||||
provider := &mockProvider{}
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.MaxTokens != 1234 {
|
||||
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
|
||||
}
|
||||
if agent.Temperature != 1.0 {
|
||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_DefaultsTemperatureWhenZero(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: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configuredTemp := 0.0
|
||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||
|
||||
provider := &mockProvider{}
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.Temperature != 0.0 {
|
||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentInstance_DefaultsTemperatureWhenUnset(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: "test-model",
|
||||
MaxTokens: 1234,
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &mockProvider{}
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.Temperature != 0.7 {
|
||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,20 +14,6 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// mockProvider is a simple mock LLM provider for testing
|
||||
type mockProvider struct{}
|
||||
|
||||
func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Mock response",
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
func TestRecordLastChannel(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
|
|
@ -185,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
|||
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
|
||||
// (actual tool retrieval is tested in tools package tests)
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsInfo := info["tools"].(map[string]any)
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
|
|
@ -260,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
al.RegisterTool(testTool)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsInfo := info["tools"].(map[string]any)
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
|
|
@ -307,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
|||
t.Fatal("Expected 'tools' key in startup info")
|
||||
}
|
||||
|
||||
toolsMap, ok := toolsInfo.(map[string]interface{})
|
||||
toolsMap, ok := toolsInfo.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' to be a map")
|
||||
}
|
||||
|
|
@ -363,7 +349,13 @@ type simpleMockProvider struct {
|
|||
response string
|
||||
}
|
||||
|
||||
func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *simpleMockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: m.response,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
|
|
@ -385,14 +377,14 @@ func (m *mockCustomTool) Description() string {
|
|||
return "Mock custom tool for testing"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (m *mockCustomTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.SilentResult("Custom tool executed")
|
||||
}
|
||||
|
||||
|
|
@ -410,14 +402,14 @@ func (m *mockContextualTool) Description() string {
|
|||
return "Mock contextual tool"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func (m *mockContextualTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
return tools.SilentResult("Contextual tool executed")
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +529,13 @@ type failFirstMockProvider struct {
|
|||
successResp string
|
||||
}
|
||||
|
||||
func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
func (m *failFirstMockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.currentCall++
|
||||
if m.currentCall <= m.failures {
|
||||
return nil, m.failError
|
||||
|
|
@ -594,12 +592,21 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
{Role: "assistant", Content: "Old response 2"},
|
||||
{Role: "user", Content: "Trigger message"},
|
||||
}
|
||||
al.sessions.SetHistory(sessionKey, history)
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("No default agent found")
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory(sessionKey, history)
|
||||
|
||||
// Call ProcessDirectWithChannel
|
||||
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
|
||||
response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
|
||||
|
||||
response, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"Trigger message",
|
||||
sessionKey,
|
||||
"test",
|
||||
"test-chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected success after retry, got error: %v", err)
|
||||
}
|
||||
|
|
@ -614,7 +621,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
}
|
||||
|
||||
// Check final history length
|
||||
finalHistory := al.sessions.GetHistory(sessionKey)
|
||||
finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
// We verify that the history has been modified (compressed)
|
||||
// Original length: 6
|
||||
// Expected behavior: compression drops ~50% of history (mid slice)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
|
|||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
// Ensure memory directory exists
|
||||
os.MkdirAll(memoryDir, 0755)
|
||||
os.MkdirAll(memoryDir, 0o755)
|
||||
|
||||
return &MemoryStore{
|
||||
workspace: workspace,
|
||||
|
|
@ -57,7 +58,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
|||
|
||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
|
||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// ReadToday reads today's daily note.
|
||||
|
|
@ -77,7 +78,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||
|
||||
// Ensure month directory exists
|
||||
monthDir := filepath.Dir(todayFile)
|
||||
os.MkdirAll(monthDir, 0755)
|
||||
os.MkdirAll(monthDir, 0o755)
|
||||
|
||||
var existingContent string
|
||||
if data, err := os.ReadFile(todayFile); err == nil {
|
||||
|
|
@ -94,13 +95,14 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||
newContent = existingContent + "\n" + content
|
||||
}
|
||||
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0644)
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
||||
}
|
||||
|
||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||
// Contents are joined with "---" separator.
|
||||
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||
var notes []string
|
||||
var sb strings.Builder
|
||||
first := true
|
||||
|
||||
for i := 0; i < days; i++ {
|
||||
date := time.Now().AddDate(0, 0, -i)
|
||||
|
|
@ -109,53 +111,41 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
|||
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
|
||||
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
notes = append(notes, string(data))
|
||||
if !first {
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
sb.Write(data)
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
if len(notes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Join with separator
|
||||
var result string
|
||||
for i, note := range notes {
|
||||
if i > 0 {
|
||||
result += "\n\n---\n\n"
|
||||
}
|
||||
result += note
|
||||
}
|
||||
return result
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetMemoryContext returns formatted memory context for the agent prompt.
|
||||
// Includes long-term memory and recent daily notes.
|
||||
func (ms *MemoryStore) GetMemoryContext() string {
|
||||
var parts []string
|
||||
|
||||
// Long-term memory
|
||||
longTerm := ms.ReadLongTerm()
|
||||
if longTerm != "" {
|
||||
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
||||
}
|
||||
|
||||
// Recent daily notes (last 3 days)
|
||||
recentNotes := ms.GetRecentDailyNotes(3)
|
||||
if recentNotes != "" {
|
||||
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
if longTerm == "" && recentNotes == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Join parts with separator
|
||||
var result string
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
result += "\n\n---\n\n"
|
||||
}
|
||||
result += part
|
||||
var sb strings.Builder
|
||||
|
||||
if longTerm != "" {
|
||||
sb.WriteString("## Long-term Memory\n\n")
|
||||
sb.WriteString(longTerm)
|
||||
}
|
||||
return fmt.Sprintf("# Memory\n\n%s", result)
|
||||
|
||||
if recentNotes != "" {
|
||||
if longTerm != "" {
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
sb.WriteString("## Recent Daily Notes\n\n")
|
||||
sb.WriteString(recentNotes)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
|
|||
26
pkg/agent/mock_provider_test.go
Normal file
26
pkg/agent/mock_provider_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
type mockProvider struct{}
|
||||
|
||||
func (m *mockProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Mock response",
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
114
pkg/agent/registry.go
Normal file
114
pkg/agent/registry.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
)
|
||||
|
||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||
type AgentRegistry struct {
|
||||
agents map[string]*AgentInstance
|
||||
resolver *routing.RouteResolver
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
||||
func NewAgentRegistry(
|
||||
cfg *config.Config,
|
||||
provider providers.LLMProvider,
|
||||
) *AgentRegistry {
|
||||
registry := &AgentRegistry{
|
||||
agents: make(map[string]*AgentInstance),
|
||||
resolver: routing.NewRouteResolver(cfg),
|
||||
}
|
||||
|
||||
agentConfigs := cfg.Agents.List
|
||||
if len(agentConfigs) == 0 {
|
||||
implicitAgent := &config.AgentConfig{
|
||||
ID: "main",
|
||||
Default: true,
|
||||
}
|
||||
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||
registry.agents["main"] = instance
|
||||
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
|
||||
} else {
|
||||
for i := range agentConfigs {
|
||||
ac := &agentConfigs[i]
|
||||
id := routing.NormalizeAgentID(ac.ID)
|
||||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||
registry.agents[id] = instance
|
||||
logger.InfoCF("agent", "Registered agent",
|
||||
map[string]any{
|
||||
"agent_id": id,
|
||||
"name": ac.Name,
|
||||
"workspace": instance.Workspace,
|
||||
"model": instance.Model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// GetAgent returns the agent instance for a given ID.
|
||||
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
id := routing.NormalizeAgentID(agentID)
|
||||
agent, ok := r.agents[id]
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
// ResolveRoute determines which agent handles the message.
|
||||
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
||||
return r.resolver.ResolveRoute(input)
|
||||
}
|
||||
|
||||
// ListAgentIDs returns all registered agent IDs.
|
||||
func (r *AgentRegistry) ListAgentIDs() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ids := make([]string, 0, len(r.agents))
|
||||
for id := range r.agents {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
||||
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
||||
parent, ok := r.GetAgent(parentAgentID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
|
||||
return false
|
||||
}
|
||||
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
||||
for _, allowed := range parent.Subagents.AllowAgents {
|
||||
if allowed == "*" {
|
||||
return true
|
||||
}
|
||||
if routing.NormalizeAgentID(allowed) == targetNorm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDefaultAgent returns the default agent instance.
|
||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if agent, ok := r.agents["main"]; ok {
|
||||
return agent
|
||||
}
|
||||
for _, agent := range r.agents {
|
||||
return agent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
205
pkg/agent/registry_test.go
Normal file
205
pkg/agent/registry_test.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
type mockRegistryProvider struct{}
|
||||
|
||||
func (m *mockRegistryProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
||||
}
|
||||
|
||||
func (m *mockRegistryProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
func testCfg(agents []config.AgentConfig) *config.Config {
|
||||
return &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: "/tmp/picoclaw-test-registry",
|
||||
Model: "gpt-4",
|
||||
MaxTokens: 8192,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
List: agents,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
||||
cfg := testCfg(nil)
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
ids := registry.ListAgentIDs()
|
||||
if len(ids) != 1 || ids[0] != "main" {
|
||||
t.Errorf("expected implicit main agent, got %v", ids)
|
||||
}
|
||||
|
||||
agent, ok := registry.GetAgent("main")
|
||||
if !ok || agent == nil {
|
||||
t.Fatal("expected to find 'main' agent")
|
||||
}
|
||||
if agent.ID != "main" {
|
||||
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "sales", Default: true, Name: "Sales Bot"},
|
||||
{ID: "support", Name: "Support Bot"},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
ids := registry.ListAgentIDs()
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
|
||||
}
|
||||
|
||||
sales, ok := registry.GetAgent("sales")
|
||||
if !ok || sales == nil {
|
||||
t.Fatal("expected to find 'sales' agent")
|
||||
}
|
||||
if sales.Name != "Sales Bot" {
|
||||
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
|
||||
}
|
||||
|
||||
support, ok := registry.GetAgent("support")
|
||||
if !ok || support == nil {
|
||||
t.Fatal("expected to find 'support' agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "my-agent", Default: true},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, ok := registry.GetAgent("My-Agent")
|
||||
if !ok || agent == nil {
|
||||
t.Fatal("expected to find agent with normalized ID")
|
||||
}
|
||||
if agent.ID != "my-agent" {
|
||||
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "alpha"},
|
||||
{ID: "beta", Default: true},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
// GetDefaultAgent first checks for "main", then returns any
|
||||
agent := registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected a default agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "parent",
|
||||
Default: true,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"child1", "child2"},
|
||||
},
|
||||
},
|
||||
{ID: "child1"},
|
||||
{ID: "child2"},
|
||||
{ID: "restricted"},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
if !registry.CanSpawnSubagent("parent", "child1") {
|
||||
t.Error("expected parent to be allowed to spawn child1")
|
||||
}
|
||||
if !registry.CanSpawnSubagent("parent", "child2") {
|
||||
t.Error("expected parent to be allowed to spawn child2")
|
||||
}
|
||||
if registry.CanSpawnSubagent("parent", "restricted") {
|
||||
t.Error("expected parent to NOT be allowed to spawn restricted")
|
||||
}
|
||||
if registry.CanSpawnSubagent("child1", "child2") {
|
||||
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "admin",
|
||||
Default: true,
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"*"},
|
||||
},
|
||||
},
|
||||
{ID: "any-agent"},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
if !registry.CanSpawnSubagent("admin", "any-agent") {
|
||||
t.Error("expected wildcard to allow spawning any agent")
|
||||
}
|
||||
if !registry.CanSpawnSubagent("admin", "nonexistent") {
|
||||
t.Error("expected wildcard to allow spawning even nonexistent agents")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstance_Model(t *testing.T) {
|
||||
model := &config.AgentModelConfig{Primary: "claude-opus"}
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "custom", Default: true, Model: model},
|
||||
})
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("custom")
|
||||
if agent.Model != "claude-opus" {
|
||||
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "inherit", Default: true},
|
||||
})
|
||||
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("inherit")
|
||||
if len(agent.Fallbacks) != 2 {
|
||||
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
||||
model := &config.AgentModelConfig{
|
||||
Primary: "gpt-4",
|
||||
Fallbacks: []string{}, // explicitly empty = disable
|
||||
}
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "no-fallback", Default: true, Model: model},
|
||||
})
|
||||
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("no-fallback")
|
||||
if len(agent.Fallbacks) != 0 {
|
||||
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
|
@ -11,6 +12,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
|
@ -19,11 +21,13 @@ import (
|
|||
)
|
||||
|
||||
type OAuthProviderConfig struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
Scopes string
|
||||
Originator string
|
||||
Port int
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string // Required for Google OAuth (confidential client)
|
||||
TokenURL string // Override token endpoint (Google uses a different URL than issuer)
|
||||
Scopes string
|
||||
Originator string
|
||||
Port int
|
||||
}
|
||||
|
||||
func OpenAIOAuthConfig() OAuthProviderConfig {
|
||||
|
|
@ -36,6 +40,32 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
|
||||
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
|
||||
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
|
||||
// These are the same client credentials used by the OpenCode antigravity plugin.
|
||||
clientID := decodeBase64(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
)
|
||||
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
|
||||
return OAuthProviderConfig{
|
||||
Issuer: "https://accounts.google.com/o/oauth2/v2",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
Port: 51121,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBase64(s string) string {
|
||||
data, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
|
|
@ -101,8 +131,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println("Waiting for authentication in browser...")
|
||||
fmt.Printf(
|
||||
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
|
||||
cfg.Port,
|
||||
)
|
||||
fmt.Println(
|
||||
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
|
||||
)
|
||||
fmt.Println("Waiting for authentication (browser or manual paste)...")
|
||||
|
||||
// Start manual input in a goroutine
|
||||
manualCh := make(chan string)
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, _ := reader.ReadString('\n')
|
||||
manualCh <- strings.TrimSpace(input)
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
|
|
@ -110,6 +154,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
return nil, result.err
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
case manualInput := <-manualCh:
|
||||
if manualInput == "" {
|
||||
return nil, fmt.Errorf("manual input cancelled")
|
||||
}
|
||||
// Extract code from URL if it's a full URL
|
||||
code := manualInput
|
||||
if strings.Contains(manualInput, "?") {
|
||||
u, err := url.Parse(manualInput)
|
||||
if err == nil {
|
||||
code = u.Query().Get("code")
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("could not find authorization code in input")
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
|
|
@ -200,8 +260,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||
deviceResp.Interval = 5
|
||||
}
|
||||
|
||||
fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
||||
cfg.Issuer, deviceResp.UserCode)
|
||||
fmt.Printf(
|
||||
"\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
||||
cfg.Issuer,
|
||||
deviceResp.UserCode,
|
||||
)
|
||||
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
||||
|
|
@ -269,8 +332,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
|||
"refresh_token": {cred.RefreshToken},
|
||||
"scope": {"openid profile email"},
|
||||
}
|
||||
if cfg.ClientSecret != "" {
|
||||
data.Set("client_secret", cfg.ClientSecret)
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
||||
tokenURL := cfg.Issuer + "/oauth/token"
|
||||
if cfg.TokenURL != "" {
|
||||
tokenURL = cfg.TokenURL
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(tokenURL, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
|
|
@ -291,6 +362,12 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
|||
if refreshed.AccountID == "" {
|
||||
refreshed.AccountID = cred.AccountID
|
||||
}
|
||||
if cred.Email != "" && refreshed.Email == "" {
|
||||
refreshed.Email = cred.Email
|
||||
}
|
||||
if cred.ProjectID != "" && refreshed.ProjectID == "" {
|
||||
refreshed.ProjectID = cred.ProjectID
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
|
|
@ -300,21 +377,35 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
|||
|
||||
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
||||
params := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {cfg.Scopes},
|
||||
"code_challenge": {pkce.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
"state": {state},
|
||||
"response_type": {"code"},
|
||||
"client_id": {cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {cfg.Scopes},
|
||||
"code_challenge": {pkce.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
||||
params.Set("originator", "picoclaw")
|
||||
|
||||
isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
|
||||
if isGoogle {
|
||||
// Google OAuth requires these for refresh token support
|
||||
params.Set("access_type", "offline")
|
||||
params.Set("prompt", "consent")
|
||||
} else {
|
||||
// OpenAI-specific parameters
|
||||
params.Set("id_token_add_organizations", "true")
|
||||
params.Set("codex_cli_simplified_flow", "true")
|
||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
||||
params.Set("originator", "picoclaw")
|
||||
}
|
||||
if cfg.Originator != "" {
|
||||
params.Set("originator", cfg.Originator)
|
||||
}
|
||||
}
|
||||
if cfg.Originator != "" {
|
||||
params.Set("originator", cfg.Originator)
|
||||
|
||||
// Google uses /auth path, OpenAI uses /oauth/authorize
|
||||
if isGoogle {
|
||||
return cfg.Issuer + "/auth?" + params.Encode()
|
||||
}
|
||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
|
@ -327,8 +418,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
|||
"client_id": {cfg.ClientID},
|
||||
"code_verifier": {codeVerifier},
|
||||
}
|
||||
if cfg.ClientSecret != "" {
|
||||
data.Set("client_secret", cfg.ClientSecret)
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
||||
tokenURL := cfg.Issuer + "/oauth/token"
|
||||
if cfg.TokenURL != "" {
|
||||
tokenURL = cfg.TokenURL
|
||||
}
|
||||
|
||||
// Determine provider name from config
|
||||
provider := "openai"
|
||||
if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
|
||||
provider = "google-antigravity"
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(tokenURL, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
||||
}
|
||||
|
|
@ -339,7 +444,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
|||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||
}
|
||||
|
||||
return parseTokenResponse(body, "openai")
|
||||
return parseTokenResponse(body, provider)
|
||||
}
|
||||
|
||||
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
||||
|
|
@ -396,15 +501,15 @@ func extractAccountID(token string) string {
|
|||
return accountID
|
||||
}
|
||||
|
||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
|
||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
|
||||
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
}
|
||||
|
||||
if orgs, ok := claims["organizations"].([]interface{}); ok {
|
||||
if orgs, ok := claims["organizations"].([]any); ok {
|
||||
for _, org := range orgs {
|
||||
if orgMap, ok := org.(map[string]interface{}); ok {
|
||||
if orgMap, ok := org.(map[string]any); ok {
|
||||
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
|
|
@ -415,7 +520,7 @@ func extractAccountID(token string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func parseJWTClaims(token string) (map[string]interface{}, error) {
|
||||
func parseJWTClaims(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("token is not a JWT")
|
||||
|
|
@ -434,7 +539,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
|
||||
func makeJWTForClaims(t *testing.T, claims map[string]any) string {
|
||||
t.Helper()
|
||||
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
|
|
@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseTokenResponse(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||
resp := map[string]interface{}{
|
||||
idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||
resp := map[string]any{
|
||||
"access_token": "opaque-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
|
||||
token := makeJWTForClaims(t, map[string]interface{}{
|
||||
"organizations": []interface{}{
|
||||
map[string]interface{}{"id": "org_from_orgs"},
|
||||
token := makeJWTForClaims(t, map[string]any{
|
||||
"organizations": []any{
|
||||
map[string]any{"id": "org_from_orgs"},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
|
|||
|
||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTWithAccountID("acc-from-id")
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "not-a-jwt",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -180,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
|||
|
||||
func makeJWTWithAccountID(accountID string) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`),
|
||||
)
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -240,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "refreshed-access-token",
|
||||
"refresh_token": "refreshed-refresh-token",
|
||||
"expires_in": 3600,
|
||||
|
|
@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
|
|||
|
||||
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"access_token": "new-access-token-only",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type AuthCredential struct {
|
|||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
}
|
||||
|
||||
type AuthStore struct {
|
||||
|
|
@ -62,7 +64,7 @@ func LoadStore() (*AuthStore, error) {
|
|||
func SaveStore(store *AuthStore) error {
|
||||
path := authFilePath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +72,7 @@ func SaveStore(store *AuthStore) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func GetCredential(provider string) (*AuthCredential, error) {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
|
|||
t.Fatalf("Stat() error: %v", err)
|
||||
}
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("file permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package channels
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
|
|
@ -19,7 +18,7 @@ type Channel interface {
|
|||
}
|
||||
|
||||
type BaseChannel struct {
|
||||
config interface{}
|
||||
config any
|
||||
messagesConfig config.MessagesConfig
|
||||
bus *bus.MessageBus
|
||||
running bool
|
||||
|
|
@ -27,7 +26,7 @@ type BaseChannel struct {
|
|||
allowList []string
|
||||
}
|
||||
|
||||
func NewBaseChannel(name string, config interface{}, messagesCfg config.MessagesConfig, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
||||
func NewBaseChannel(name string, config any, messagesCfg config.MessagesConfig, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
||||
return &BaseChannel{
|
||||
config: config,
|
||||
messagesConfig: messagesCfg,
|
||||
|
|
@ -95,17 +94,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
|
|||
return
|
||||
}
|
||||
|
||||
// Build session key: channel:chatID
|
||||
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
|
||||
|
||||
msg := bus.InboundMessage{
|
||||
Channel: c.name,
|
||||
SenderID: senderID,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Media: media,
|
||||
SessionKey: sessionKey,
|
||||
Metadata: metadata,
|
||||
Channel: c.name,
|
||||
SenderID: senderID,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Media: media,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
c.bus.PublishInbound(msg)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
|
||||
logger.DebugCF("dingtalk", "Sending message", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
|
@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
||||
// This is called by the Stream SDK when a new message arrives
|
||||
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
|
||||
func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||
ctx context.Context,
|
||||
data *chatbot.BotCallbackDataModel,
|
||||
) ([]byte, error) {
|
||||
// Extract message content from Text field
|
||||
content := data.Text.Content
|
||||
if content == "" {
|
||||
// Try to extract from Content interface{} if Text is empty
|
||||
if contentMap, ok := data.Content.(map[string]interface{}); ok {
|
||||
if contentMap, ok := data.Content.(map[string]any); ok {
|
||||
if textContent, ok := contentMap["content"].(string); ok {
|
||||
content = textContent
|
||||
}
|
||||
|
|
@ -155,7 +159,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
|
|||
"session_webhook": data.SessionWebhook,
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
|
||||
if data.ConversationType == "1" {
|
||||
metadata["peer_kind"] = "direct"
|
||||
metadata["peer_id"] = senderID
|
||||
} else {
|
||||
metadata["peer_kind"] = "group"
|
||||
metadata["peer_id"] = data.ConversationId
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Received message", map[string]any{
|
||||
"sender_nick": senderNick,
|
||||
"sender_id": senderID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -184,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
|
|||
titleBytes,
|
||||
contentBytes,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -26,6 +28,9 @@ type DiscordChannel struct {
|
|||
config config.DiscordConfig
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
typingMu sync.Mutex
|
||||
typingStop map[string]chan struct{} // chatID → stop signal
|
||||
botUserID string // stored for mention checking
|
||||
}
|
||||
|
||||
func NewDiscordChannel(cfg config.DiscordConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||
|
|
@ -42,6 +47,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, messagesCfg config.MessagesConf
|
|||
config: cfg,
|
||||
transcriber: nil,
|
||||
ctx: context.Background(),
|
||||
typingStop: make(map[string]chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +66,14 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
|
|||
logger.InfoC("discord", "Starting Discord bot")
|
||||
|
||||
c.ctx = ctx
|
||||
|
||||
// Get bot user ID before opening session to avoid race condition
|
||||
botUser, err := c.session.User("@me")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get bot user: %w", err)
|
||||
}
|
||||
c.botUserID = botUser.ID
|
||||
|
||||
c.session.AddHandler(c.handleMessage)
|
||||
|
||||
if err := c.session.Open(); err != nil {
|
||||
|
|
@ -68,10 +82,6 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
|
|||
|
||||
c.setRunning(true)
|
||||
|
||||
botUser, err := c.session.User("@me")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get bot user: %w", err)
|
||||
}
|
||||
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
||||
"username": botUser.Username,
|
||||
"user_id": botUser.ID,
|
||||
|
|
@ -84,6 +94,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
|||
logger.InfoC("discord", "Stopping Discord bot")
|
||||
c.setRunning(false)
|
||||
|
||||
// Stop all typing goroutines before closing session
|
||||
c.typingMu.Lock()
|
||||
for chatID, stop := range c.typingStop {
|
||||
close(stop)
|
||||
delete(c.typingStop, chatID)
|
||||
}
|
||||
c.typingMu.Unlock()
|
||||
|
||||
if err := c.session.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close discord session: %w", err)
|
||||
}
|
||||
|
|
@ -92,6 +110,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
c.stopTyping(msg.ChatID)
|
||||
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("discord bot not running")
|
||||
}
|
||||
|
|
@ -106,7 +126,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
|
||||
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||
|
|
@ -117,134 +137,8 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
// splitMessage splits long messages into chunks, preserving code block integrity
|
||||
// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks
|
||||
func splitMessage(content string, limit int) []string {
|
||||
var messages []string
|
||||
|
||||
for len(content) > 0 {
|
||||
if len(content) <= limit {
|
||||
messages = append(messages, content)
|
||||
break
|
||||
}
|
||||
|
||||
msgEnd := limit
|
||||
|
||||
// Find natural split point within the limit
|
||||
msgEnd = findLastNewline(content[:limit], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:limit], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
// Check if this would end with an incomplete code block
|
||||
candidate := content[:msgEnd]
|
||||
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
||||
|
||||
if unclosedIdx >= 0 {
|
||||
// Message would end with incomplete code block
|
||||
// Try to extend to include the closing ``` (with some buffer)
|
||||
extendedLimit := limit + 500 // Allow 500 char buffer for code blocks
|
||||
if len(content) > extendedLimit {
|
||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
||||
if closingIdx > 0 && closingIdx <= extendedLimit {
|
||||
// Extend to include the closing ```
|
||||
msgEnd = closingIdx
|
||||
} else {
|
||||
// Can't find closing, split before the code block
|
||||
msgEnd = findLastNewline(content[:unclosedIdx], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:unclosedIdx], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = unclosedIdx
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remaining content fits within extended limit
|
||||
msgEnd = len(content)
|
||||
}
|
||||
}
|
||||
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
messages = append(messages, content[:msgEnd])
|
||||
content = strings.TrimSpace(content[msgEnd:])
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
||||
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
||||
func findLastUnclosedCodeBlock(text string) int {
|
||||
count := 0
|
||||
lastOpenIdx := -1
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||
if count == 0 {
|
||||
lastOpenIdx = i
|
||||
}
|
||||
count++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
// If odd number of ``` markers, last one is unclosed
|
||||
if count%2 == 1 {
|
||||
return lastOpenIdx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
||||
// Returns the position after the closing ``` or -1 if not found
|
||||
func findNextClosingCodeBlock(text string, startIdx int) int {
|
||||
for i := startIdx; i < len(text); i++ {
|
||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||
return i + 3
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findLastNewline finds the last newline character within the last N characters
|
||||
// Returns the position of the newline or -1 if not found
|
||||
func findLastNewline(s string, searchWindow int) int {
|
||||
searchStart := len(s) - searchWindow
|
||||
if searchStart < 0 {
|
||||
searchStart = 0
|
||||
}
|
||||
for i := len(s) - 1; i >= searchStart; i-- {
|
||||
if s[i] == '\n' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findLastSpace finds the last space character within the last N characters
|
||||
// Returns the position of the space or -1 if not found
|
||||
func findLastSpace(s string, searchWindow int) int {
|
||||
searchStart := len(s) - searchWindow
|
||||
if searchStart < 0 {
|
||||
searchStart = 0
|
||||
}
|
||||
for i := len(s) - 1; i >= searchStart; i-- {
|
||||
if s[i] == ' ' || s[i] == '\t' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
||||
// 使用传入的 ctx 进行超时控制
|
||||
// Use the passed ctx for timeout control
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -265,7 +159,7 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
|
|||
}
|
||||
}
|
||||
|
||||
// appendContent 安全地追加内容到现有文本
|
||||
// appendContent safely appends content to existing text
|
||||
func appendContent(content, suffix string) string {
|
||||
if content == "" {
|
||||
return suffix
|
||||
|
|
@ -282,13 +176,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
return
|
||||
}
|
||||
|
||||
if err := c.session.ChannelTyping(m.ChannelID); err != nil {
|
||||
logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
||||
// Check allowlist first to avoid downloading attachments and transcribing for rejected users
|
||||
if !c.IsAllowed(m.Author.ID) {
|
||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": m.Author.ID,
|
||||
|
|
@ -296,6 +184,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
return
|
||||
}
|
||||
|
||||
// If configured to only respond to mentions, check if bot is mentioned
|
||||
// Skip this check for DMs (GuildID is empty) - DMs should always be responded to
|
||||
if c.config.MentionOnly && m.GuildID != "" {
|
||||
isMentioned := false
|
||||
for _, mention := range m.Mentions {
|
||||
if mention.ID == c.botUserID {
|
||||
isMentioned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMentioned {
|
||||
logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
|
||||
"user_id": m.Author.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
senderID := m.Author.ID
|
||||
senderName := m.Author.Username
|
||||
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
||||
|
|
@ -303,10 +209,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
}
|
||||
|
||||
content := m.Content
|
||||
content = c.stripBotMention(content)
|
||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||
localFiles := make([]string, 0, len(m.Attachments))
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
// Ensure temp files are cleaned up when function returns
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
|
|
@ -330,7 +237,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
|
||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
cancel() // 立即释放context资源,避免在for循环中泄漏
|
||||
cancel() // Release context resources immediately to avoid leaks in for loop
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
|
||||
|
|
@ -370,12 +277,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
content = "[media only]"
|
||||
}
|
||||
|
||||
// Start typing after all early returns — guaranteed to have a matching Send()
|
||||
c.startTyping(m.ChannelID)
|
||||
|
||||
logger.DebugCF("discord", "Received message", map[string]any{
|
||||
"sender_name": senderName,
|
||||
"sender_id": senderID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
peerKind := "channel"
|
||||
peerID := m.ChannelID
|
||||
if m.GuildID == "" {
|
||||
peerKind = "direct"
|
||||
peerID = senderID
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": m.ID,
|
||||
"user_id": senderID,
|
||||
|
|
@ -384,13 +301,73 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
"guild_id": m.GuildID,
|
||||
"channel_id": m.ChannelID,
|
||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
||||
"peer_kind": peerKind,
|
||||
"peer_id": peerID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||
// It stops any existing typing loop for that chatID before starting a new one.
|
||||
func (c *DiscordChannel) startTyping(chatID string) {
|
||||
c.typingMu.Lock()
|
||||
// Stop existing loop for this chatID if any
|
||||
if stop, ok := c.typingStop[chatID]; ok {
|
||||
close(stop)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
c.typingStop[chatID] = stop
|
||||
c.typingMu.Unlock()
|
||||
|
||||
go func() {
|
||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
||||
}
|
||||
ticker := time.NewTicker(8 * time.Second)
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(5 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-timeout:
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.session.ChannelTyping(chatID); err != nil {
|
||||
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopTyping stops the typing indicator loop for the given chatID.
|
||||
func (c *DiscordChannel) stopTyping(chatID string) {
|
||||
c.typingMu.Lock()
|
||||
defer c.typingMu.Unlock()
|
||||
if stop, ok := c.typingStop[chatID]; ok {
|
||||
close(stop)
|
||||
delete(c.typingStop, chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "discord",
|
||||
})
|
||||
}
|
||||
|
||||
// stripBotMention removes the bot mention from the message content.
|
||||
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
||||
func (c *DiscordChannel) stripBotMention(text string) string {
|
||||
if c.botUserID == "" {
|
||||
return text
|
||||
}
|
||||
// Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ type FeishuChannel struct {
|
|||
|
||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
|
||||
return nil, errors.New(
|
||||
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
|
||||
)
|
||||
}
|
||||
|
||||
// Start is a stub method to satisfy the Channel interface
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
|||
|
||||
go func() {
|
||||
if err := wsClient.Start(runCtx); err != nil {
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
|
||||
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
|
||||
|
|
@ -187,7 +187,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
|||
metadata["tenant_key"] = *sender.TenantKey
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
||||
if chatType == "p2p" {
|
||||
metadata["peer_kind"] = "direct"
|
||||
metadata["peer_id"] = senderID
|
||||
} else {
|
||||
metadata["peer_kind"] = "group"
|
||||
metadata["peer_id"] = chatID
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 80),
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
|||
|
||||
// Fetch bot profile to get bot's userId for mention detection
|
||||
if err := c.fetchBotInfo(); err != nil {
|
||||
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
|
||||
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
||||
"bot_user_id": c.botUserID,
|
||||
"basic_id": c.botBasicID,
|
||||
"display_name": c.botDisplayName,
|
||||
|
|
@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
go func() {
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
|
||||
"addr": addr,
|
||||
"path": path,
|
||||
})
|
||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
|
|||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
|
@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Events []lineEvent `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
|
@ -266,7 +266,7 @@ type lineMentionee struct {
|
|||
|
||||
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
if event.Type != "message" {
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
||||
"type": event.Type,
|
||||
})
|
||||
return
|
||||
|
|
@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
|
||||
var msg lineMessage
|
||||
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
|
|
@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
|
||||
// In group chats, only respond when the bot is mentioned
|
||||
if isGroup && !c.isBotMentioned(msg) {
|
||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
|
||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
return
|
||||
|
|
@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -366,7 +366,15 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
|||
"message_id": msg.ID,
|
||||
}
|
||||
|
||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||
if isGroup {
|
||||
metadata["peer_kind"] = "group"
|
||||
metadata["peer_id"] = chatID
|
||||
} else {
|
||||
metadata["peer_kind"] = "direct"
|
||||
metadata["peer_id"] = senderID
|
||||
}
|
||||
|
||||
logger.DebugCF("line", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_type": msg.Type,
|
||||
|
|
@ -497,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
tokenEntry := entry.(replyTokenEntry)
|
||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"quoted": quoteToken != "",
|
||||
})
|
||||
|
|
@ -525,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
|
|||
|
||||
// sendReply sends a message using the LINE Reply API.
|
||||
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"replyToken": replyToken,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
|
@ -535,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
|
|||
|
||||
// sendPush sends a message using the LINE Push API.
|
||||
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"to": to,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
|
@ -545,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
|
|||
|
||||
// sendLoading sends a loading animation indicator to the chat.
|
||||
func (c *LINEChannel) sendLoading(chatID string) {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chatId": chatID,
|
||||
"loadingSeconds": 60,
|
||||
}
|
||||
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
|
||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// callAPI makes an authenticated POST request to the LINE API.
|
||||
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
|
||||
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
|
|
|
|||
|
|
@ -18,14 +18,13 @@ type MaixCamChannel struct {
|
|||
listener net.Listener
|
||||
clients map[net.Conn]bool
|
||||
clientsMux sync.RWMutex
|
||||
running bool
|
||||
}
|
||||
|
||||
type MaixCamMessage struct {
|
||||
Type string `json:"type"`
|
||||
Tips string `json:"tips"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Type string `json:"type"`
|
||||
Tips string `json:"tips"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func NewMaixCamChannel(cfg config.MaixCamConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
||||
|
|
@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, messagesCfg config.MessagesConf
|
|||
BaseChannel: base,
|
||||
config: cfg,
|
||||
clients: make(map[net.Conn]bool),
|
||||
running: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error {
|
|||
c.listener = listener
|
||||
c.setRunning(true)
|
||||
|
||||
logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
|
||||
"host": c.config.Host,
|
||||
"port": c.config.Port,
|
||||
})
|
||||
|
|
@ -73,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
|
|||
conn, err := c.listener.Accept()
|
||||
if err != nil {
|
||||
if c.running {
|
||||
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
|
|
@ -114,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
|
|||
var msg MaixCamMessage
|
||||
if err := decoder.Decode(&msg); err != nil {
|
||||
if err.Error() != "EOF" {
|
||||
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -135,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
|
|||
case "status":
|
||||
c.handleStatusUpdate(msg)
|
||||
default:
|
||||
logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
|
||||
logger.WarnCF("maixcam", "Unknown message type", map[string]any{
|
||||
"type": msg.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||
logger.InfoCF("maixcam", "", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "", map[string]any{
|
||||
"timestamp": msg.Timestamp,
|
||||
"data": msg.Data,
|
||||
})
|
||||
|
|
@ -172,13 +170,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
|||
"y": fmt.Sprintf("%.0f", y),
|
||||
"w": fmt.Sprintf("%.0f", w),
|
||||
"h": fmt.Sprintf("%.0f", h),
|
||||
"peer_kind": "channel",
|
||||
"peer_id": "default",
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
||||
}
|
||||
|
||||
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
|
||||
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
|
||||
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
|
||||
"status": msg.Data,
|
||||
})
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return fmt.Errorf("no connected MaixCam devices")
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"type": "command",
|
||||
"timestamp": float64(0),
|
||||
"message": msg.Content,
|
||||
|
|
@ -231,7 +231,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
var sendErr error
|
||||
for conn := range c.clients {
|
||||
if _, err := conn.Write(data); err != nil {
|
||||
logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
|
||||
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
|
||||
"client": conn.RemoteAddr().String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Telegram channel")
|
||||
telegram, err := NewTelegramChannel(m.config, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
||||
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
||||
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
||||
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
||||
qq, err := NewQQChannel(m.config.Channels.QQ, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
||||
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
||||
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
|
|||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
|
@ -176,7 +176,33 @@ func (m *Manager) initChannels() error {
|
|||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
||||
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
||||
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["wecom"] = wecom
|
||||
logger.InfoC("channels", "WeCom channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
||||
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.config.Messages, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["wecom_app"] = wecomApp
|
||||
logger.InfoC("channels", "WeCom App channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
||||
|
|
@ -200,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
|||
go m.dispatchOutbound(dispatchCtx)
|
||||
|
||||
for name, channel := range m.channels {
|
||||
logger.InfoCF("channels", "Starting channel", map[string]interface{}{
|
||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||
"channel": name,
|
||||
})
|
||||
if err := channel.Start(ctx); err != nil {
|
||||
logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
|
||||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -227,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
|||
}
|
||||
|
||||
for name, channel := range m.channels {
|
||||
logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
|
||||
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
||||
"channel": name,
|
||||
})
|
||||
if err := channel.Stop(ctx); err != nil {
|
||||
logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
|
||||
"channel": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -266,14 +292,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
|
||||
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
|
||||
"channel": msg.Channel,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := channel.Send(ctx, msg); err != nil {
|
||||
logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
|
||||
logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
|
||||
"channel": msg.Channel,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -289,13 +315,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
|
|||
return channel, ok
|
||||
}
|
||||
|
||||
func (m *Manager) GetStatus() map[string]interface{} {
|
||||
func (m *Manager) GetStatus() map[string]any {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
status := make(map[string]interface{})
|
||||
status := make(map[string]any)
|
||||
for name, channel := range m.channels {
|
||||
status[name] = map[string]interface{}{
|
||||
status[name] = map[string]any{
|
||||
"enabled": true,
|
||||
"running": channel.IsRunning(),
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -77,7 +77,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
return fmt.Errorf("failed to get websocket info: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Got WebSocket info", map[string]any{
|
||||
"shards": wsInfo.Shards,
|
||||
})
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
||||
go func() {
|
||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.setRunning(false)
|
||||
|
|
@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
// C2C 消息发送
|
||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||
if err != nil {
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
|
|
@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Received C2C message", map[string]any{
|
||||
"sender": senderID,
|
||||
"length": len(content),
|
||||
})
|
||||
|
|
@ -165,6 +165,8 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
// 转发到消息总线
|
||||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
"peer_kind": "direct",
|
||||
"peer_id": senderID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
|
||||
|
|
@ -197,7 +199,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
|
||||
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
||||
"sender": senderID,
|
||||
"group": data.GroupID,
|
||||
"length": len(content),
|
||||
|
|
@ -207,6 +209,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
"group_id": data.GroupID,
|
||||
"peer_kind": "group",
|
||||
"peer_id": data.GroupID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type SlackChannel struct {
|
|||
api *slack.Client
|
||||
socketClient *socketmode.Client
|
||||
botUserID string
|
||||
teamID string
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
|
@ -72,8 +73,9 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
|||
return fmt.Errorf("slack auth test failed: %w", err)
|
||||
}
|
||||
c.botUserID = authResp.UserID
|
||||
c.teamID = authResp.TeamID
|
||||
|
||||
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
||||
logger.InfoCF("slack", "Slack bot connected", map[string]any{
|
||||
"bot_user_id": c.botUserID,
|
||||
"team": authResp.Team,
|
||||
})
|
||||
|
|
@ -83,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
|||
go func() {
|
||||
if err := c.socketClient.RunContext(c.ctx); err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{
|
||||
logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -138,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
})
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Message sent", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Message sent", map[string]any{
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
})
|
||||
|
|
@ -200,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
|
|
@ -236,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -259,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
|
||||
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||
|
|
@ -274,14 +276,24 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
peerKind := "channel"
|
||||
peerID := channelID
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
peerKind = "direct"
|
||||
peerID = senderID
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_ts": messageTS,
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
"platform": "slack",
|
||||
"peer_kind": peerKind,
|
||||
"peer_id": peerID,
|
||||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -297,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
|||
}
|
||||
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
|
|
@ -331,12 +343,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
mentionPeerKind := "channel"
|
||||
mentionPeerID := channelID
|
||||
if strings.HasPrefix(channelID, "D") {
|
||||
mentionPeerKind = "direct"
|
||||
mentionPeerID = senderID
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_ts": messageTS,
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
"platform": "slack",
|
||||
"is_mention": "true",
|
||||
"peer_kind": mentionPeerKind,
|
||||
"peer_id": mentionPeerID,
|
||||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
|
|
@ -353,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
|||
}
|
||||
|
||||
if !c.IsAllowed(cmd.UserID) {
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
||||
"user_id": cmd.UserID,
|
||||
})
|
||||
return
|
||||
|
|
@ -373,9 +395,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
|||
"platform": "slack",
|
||||
"is_command": "true",
|
||||
"trigger_id": cmd.TriggerID,
|
||||
"peer_kind": "channel",
|
||||
"peer_id": channelID,
|
||||
"team_id": c.teamID,
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
||||
logger.DebugCF("slack", "Slash command received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"command": cmd.Command,
|
||||
"text": utils.Truncate(content, 50),
|
||||
|
|
@ -390,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
|||
downloadURL = file.URLPrivate
|
||||
}
|
||||
if downloadURL == "" {
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegohandler"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
|
|
@ -59,6 +58,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
|||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
}))
|
||||
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
|
||||
// Use environment proxy if configured
|
||||
opts = append(opts, telego.WithHTTPClient(&http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
||||
|
|
@ -120,7 +126,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
}, th.AnyMessage())
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
||||
"username": c.bot.Username(),
|
||||
})
|
||||
|
||||
|
|
@ -133,6 +139,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||
c.setRunning(false)
|
||||
|
|
@ -175,7 +182,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
tgMsg.ParseMode = telego.ModeHTML
|
||||
|
||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
tgMsg.ParseMode = ""
|
||||
|
|
@ -203,7 +210,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": senderID,
|
||||
})
|
||||
return nil
|
||||
|
|
@ -220,7 +227,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
|
@ -260,19 +267,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.transcriber.Transcribe(ctx, voicePath)
|
||||
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
"path": voicePath,
|
||||
})
|
||||
transcribedText = "[voice (transcription failed)]"
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
|
|
@ -315,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
content = "[empty message]"
|
||||
}
|
||||
|
||||
logger.DebugCF("telegram", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": fmt.Sprintf("%d", chatID),
|
||||
"preview": utils.Truncate(content, 50),
|
||||
|
|
@ -324,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
// Thinking indicator
|
||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
|
@ -347,12 +354,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
c.placeholders.Store(chatIDStr, pID)
|
||||
}
|
||||
|
||||
peerKind := "direct"
|
||||
peerID := fmt.Sprintf("%d", user.ID)
|
||||
if message.Chat.Type != "private" {
|
||||
peerKind = "group"
|
||||
peerID = fmt.Sprintf("%d", chatID)
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
"user_id": fmt.Sprintf("%d", user.ID),
|
||||
"username": user.Username,
|
||||
"first_name": user.FirstName,
|
||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||
"peer_kind": peerKind,
|
||||
"peer_id": peerID,
|
||||
}
|
||||
|
||||
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
||||
|
|
@ -362,7 +378,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
|
|
@ -377,7 +393,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
|||
}
|
||||
|
||||
url := c.bot.FileDownloadURL(file.FilePath)
|
||||
logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
|
||||
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
|
||||
|
||||
// Use FilePath as filename for better identification
|
||||
filename := file.FilePath + ext
|
||||
|
|
@ -389,7 +405,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
|||
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
|
|
@ -447,7 +463,11 @@ func markdownToTelegramHTML(text string) string {
|
|||
|
||||
for i, code := range codeBlocks.codes {
|
||||
escaped := escapeHTML(code)
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
|
||||
text = strings.ReplaceAll(
|
||||
text,
|
||||
fmt.Sprintf("\x00CB%d\x00", i),
|
||||
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
|
||||
)
|
||||
}
|
||||
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ func commandArgs(text string) string {
|
|||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
||||
msg := `/start - Start the bot
|
||||
/help - Show this help message
|
||||
|
|
@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||
args := commandArgs(message.Text)
|
||||
if args == "" {
|
||||
|
|
|
|||
605
pkg/channels/wecom.go
Normal file
605
pkg/channels/wecom.go
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// WeCom Bot (企业微信智能机器人) channel implementation
|
||||
// Uses webhook callback mode for receiving messages and webhook API for sending replies
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
||||
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
||||
type WeComBotChannel struct {
|
||||
*BaseChannel
|
||||
config config.WeComConfig
|
||||
server *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||
msgMu sync.RWMutex
|
||||
}
|
||||
|
||||
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
|
||||
type WeComBotMessage struct {
|
||||
MsgID string `json:"msgid"`
|
||||
AIBotID string `json:"aibotid"`
|
||||
ChatID string `json:"chatid"` // Session ID, only present for group chats
|
||||
ChatType string `json:"chattype"` // "single" for DM, "group" for group chat
|
||||
From struct {
|
||||
UserID string `json:"userid"`
|
||||
} `json:"from"`
|
||||
ResponseURL string `json:"response_url"`
|
||||
MsgType string `json:"msgtype"` // text, image, voice, file, mixed
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image"`
|
||||
Voice struct {
|
||||
Content string `json:"content"` // Voice to text content
|
||||
} `json:"voice"`
|
||||
File struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"file"`
|
||||
Mixed struct {
|
||||
MsgItem []struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Image struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image"`
|
||||
} `json:"msg_item"`
|
||||
} `json:"mixed"`
|
||||
Quote struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
} `json:"quote"`
|
||||
}
|
||||
|
||||
// WeComBotReplyMessage represents the reply message structure
|
||||
type WeComBotReplyMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
||||
func NewWeComBotChannel(cfg config.WeComConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
||||
if cfg.Token == "" || cfg.WebhookURL == "" {
|
||||
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||
}
|
||||
|
||||
base := NewBaseChannel("wecom", cfg, messagesCfg, messageBus, []string(cfg.AllowFrom))
|
||||
|
||||
return &WeComBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
processedMsgs: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the channel name
|
||||
func (c *WeComBotChannel) Name() string {
|
||||
return "wecom"
|
||||
}
|
||||
|
||||
// Start initializes the WeCom Bot channel with HTTP webhook server
|
||||
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Setup HTTP server for webhook
|
||||
mux := http.NewServeMux()
|
||||
webhookPath := c.config.WebhookPath
|
||||
if webhookPath == "" {
|
||||
webhookPath = "/webhook/wecom"
|
||||
}
|
||||
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||
|
||||
// Health check endpoint
|
||||
mux.HandleFunc("/health/wecom", c.handleHealth)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||
c.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
|
||||
"address": addr,
|
||||
"path": webhookPath,
|
||||
})
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("wecom", "HTTP server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the WeCom Bot channel
|
||||
func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("wecom", "Stopping WeCom Bot channel...")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
if c.server != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
c.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to WeCom user via webhook API
|
||||
// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message
|
||||
// For delayed responses, we use the webhook URL
|
||||
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("wecom channel not running")
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
||||
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
||||
}
|
||||
|
||||
// handleWebhook handles incoming webhook requests from WeCom
|
||||
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// Handle verification request
|
||||
c.handleVerification(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Handle message callback
|
||||
c.handleMessageCallback(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// handleVerification handles the URL verification request from WeCom
|
||||
func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
echostr := query.Get("echostr")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||
logger.WarnC("wecom", "Signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt echostr
|
||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove BOM and whitespace as per WeCom documentation
|
||||
// The response must be plain text without quotes, BOM, or newlines
|
||||
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||
w.Write([]byte(decryptedEchoStr))
|
||||
}
|
||||
|
||||
// handleMessageCallback handles incoming messages from WeCom
|
||||
func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse XML to get encrypted message
|
||||
var encryptedMsg struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||
logger.WarnC("wecom", "Message signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt message
|
||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted JSON message (AIBOT uses JSON format)
|
||||
var msg WeComBotMessage
|
||||
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the message asynchronously with context
|
||||
go c.processMessage(ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom Bot requires response within configured timeout (default 5 seconds)
|
||||
w.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// processMessage processes the received message
|
||||
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
|
||||
// Skip unsupported message types
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
|
||||
msg.MsgType != "mixed" {
|
||||
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||
msgID := msg.MsgID
|
||||
c.msgMu.Lock()
|
||||
if c.processedMsgs[msgID] {
|
||||
c.msgMu.Unlock()
|
||||
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.processedMsgs[msgID] = true
|
||||
c.msgMu.Unlock()
|
||||
|
||||
// Clean up old messages periodically (keep last 1000)
|
||||
if len(c.processedMsgs) > 1000 {
|
||||
c.msgMu.Lock()
|
||||
c.processedMsgs = make(map[string]bool)
|
||||
c.msgMu.Unlock()
|
||||
}
|
||||
|
||||
senderID := msg.From.UserID
|
||||
|
||||
// Determine if this is a group chat or direct message
|
||||
// ChatType: "single" for DM, "group" for group chat
|
||||
isGroupChat := msg.ChatType == "group"
|
||||
|
||||
var chatID, peerKind, peerID string
|
||||
if isGroupChat {
|
||||
// Group chat: use ChatID as chatID and peer_id
|
||||
chatID = msg.ChatID
|
||||
peerKind = "group"
|
||||
peerID = msg.ChatID
|
||||
} else {
|
||||
// Direct message: use senderID as chatID and peer_id
|
||||
chatID = senderID
|
||||
peerKind = "direct"
|
||||
peerID = senderID
|
||||
}
|
||||
|
||||
// Extract content based on message type
|
||||
var content string
|
||||
switch msg.MsgType {
|
||||
case "text":
|
||||
content = msg.Text.Content
|
||||
case "voice":
|
||||
content = msg.Voice.Content // Voice to text content
|
||||
case "mixed":
|
||||
// For mixed messages, concatenate text items
|
||||
for _, item := range msg.Mixed.MsgItem {
|
||||
if item.MsgType == "text" {
|
||||
content += item.Text.Content
|
||||
}
|
||||
}
|
||||
case "image", "file":
|
||||
// For image and file, we don't have text content
|
||||
content = ""
|
||||
}
|
||||
|
||||
// Build metadata
|
||||
metadata := map[string]string{
|
||||
"msg_type": msg.MsgType,
|
||||
"msg_id": msg.MsgID,
|
||||
"platform": "wecom",
|
||||
"peer_kind": peerKind,
|
||||
"peer_id": peerID,
|
||||
"response_url": msg.ResponseURL,
|
||||
}
|
||||
if isGroupChat {
|
||||
metadata["chat_id"] = msg.ChatID
|
||||
metadata["sender_id"] = senderID
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"peer_kind": peerKind,
|
||||
"is_group_chat": isGroupChat,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
}
|
||||
|
||||
// sendWebhookReply sends a reply using the webhook URL
|
||||
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
|
||||
reply := WeComBotReplyMessage{
|
||||
MsgType: "text",
|
||||
}
|
||||
reply.Text.Content = content
|
||||
|
||||
jsonData, err := json.Marshal(reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal reply: %w", err)
|
||||
}
|
||||
|
||||
// Use configurable timeout (default 5 seconds)
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook reply: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
// WeCom common utilities for both WeCom Bot and WeCom App
|
||||
// The following functions were moved from wecom_common.go
|
||||
|
||||
// WeComVerifySignature verifies the message signature for WeCom
|
||||
// This is a common function used by both WeCom Bot and WeCom App
|
||||
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||
if token == "" {
|
||||
return true // Skip verification if token is not set
|
||||
}
|
||||
|
||||
// Sort parameters
|
||||
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||
sort.Strings(params)
|
||||
|
||||
// Concatenate
|
||||
str := strings.Join(params, "")
|
||||
|
||||
// SHA1 hash
|
||||
hash := sha1.Sum([]byte(str))
|
||||
expectedSignature := fmt.Sprintf("%x", hash)
|
||||
|
||||
return expectedSignature == msgSignature
|
||||
}
|
||||
|
||||
// WeComDecryptMessage decrypts the encrypted message using AES
|
||||
// This is a common function used by both WeCom Bot and WeCom App
|
||||
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
|
||||
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
|
||||
return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
|
||||
}
|
||||
|
||||
// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
|
||||
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
|
||||
func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
|
||||
if encodingAESKey == "" {
|
||||
// No encryption, return as is (base64 decode)
|
||||
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
// Decode AES key (base64)
|
||||
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
||||
}
|
||||
|
||||
// Decode encrypted message
|
||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||
}
|
||||
|
||||
// AES decrypt
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
if len(cipherText) < aes.BlockSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
// IV is the first 16 bytes of AESKey
|
||||
iv := aesKey[:aes.BlockSize]
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plainText := make([]byte, len(cipherText))
|
||||
mode.CryptBlocks(plainText, cipherText)
|
||||
|
||||
// Remove PKCS7 padding
|
||||
plainText, err = pkcs7UnpadWeCom(plainText)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to unpad: %w", err)
|
||||
}
|
||||
|
||||
// Parse message structure
|
||||
// Format: random(16) + msg_len(4) + msg + receiveid
|
||||
if len(plainText) < 20 {
|
||||
return "", fmt.Errorf("decrypted message too short")
|
||||
}
|
||||
|
||||
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
||||
if int(msgLen) > len(plainText)-20 {
|
||||
return "", fmt.Errorf("invalid message length")
|
||||
}
|
||||
|
||||
msg := plainText[20 : 20+msgLen]
|
||||
|
||||
// Verify receiveid if provided
|
||||
if receiveid != "" && len(plainText) > 20+int(msgLen) {
|
||||
actualReceiveID := string(plainText[20+msgLen:])
|
||||
if actualReceiveID != receiveid {
|
||||
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
|
||||
}
|
||||
}
|
||||
|
||||
return string(msg), nil
|
||||
}
|
||||
|
||||
// pkcs7UnpadWeCom removes PKCS7 padding with validation
|
||||
// WeCom uses block size of 32 (not standard AES block size of 16)
|
||||
const wecomBlockSize = 32
|
||||
|
||||
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
// WeCom uses 32-byte block size for PKCS7 padding
|
||||
if padding == 0 || padding > wecomBlockSize {
|
||||
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||
}
|
||||
if padding > len(data) {
|
||||
return nil, fmt.Errorf("padding size larger than data")
|
||||
}
|
||||
// Verify all padding bytes
|
||||
for i := 0; i < padding; i++ {
|
||||
if data[len(data)-1-i] != byte(padding) {
|
||||
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
639
pkg/channels/wecom_app.go
Normal file
639
pkg/channels/wecom_app.go
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// WeCom App (企业微信自建应用) channel implementation
|
||||
// Supports receiving messages via webhook callback and sending messages proactively
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomAPIBase = "https://qyapi.weixin.qq.com"
|
||||
)
|
||||
|
||||
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
||||
type WeComAppChannel struct {
|
||||
*BaseChannel
|
||||
config config.WeComAppConfig
|
||||
server *http.Server
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||
msgMu sync.RWMutex
|
||||
}
|
||||
|
||||
// WeComXMLMessage represents the XML message structure from WeCom
|
||||
type WeComXMLMessage struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
MsgId int64 `xml:"MsgId"`
|
||||
AgentID int64 `xml:"AgentID"`
|
||||
PicUrl string `xml:"PicUrl"`
|
||||
MediaId string `xml:"MediaId"`
|
||||
Format string `xml:"Format"`
|
||||
ThumbMediaId string `xml:"ThumbMediaId"`
|
||||
LocationX float64 `xml:"Location_X"`
|
||||
LocationY float64 `xml:"Location_Y"`
|
||||
Scale int `xml:"Scale"`
|
||||
Label string `xml:"Label"`
|
||||
Title string `xml:"Title"`
|
||||
Description string `xml:"Description"`
|
||||
Url string `xml:"Url"`
|
||||
Event string `xml:"Event"`
|
||||
EventKey string `xml:"EventKey"`
|
||||
}
|
||||
|
||||
// WeComTextMessage represents text message for sending
|
||||
type WeComTextMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Text struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"text"`
|
||||
Safe int `json:"safe,omitempty"`
|
||||
}
|
||||
|
||||
// WeComMarkdownMessage represents markdown message for sending
|
||||
type WeComMarkdownMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Markdown struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"markdown"`
|
||||
}
|
||||
|
||||
// WeComImageMessage represents image message for sending
|
||||
type WeComImageMessage struct {
|
||||
ToUser string `json:"touser"`
|
||||
MsgType string `json:"msgtype"`
|
||||
AgentID int64 `json:"agentid"`
|
||||
Image struct {
|
||||
MediaID string `json:"media_id"`
|
||||
} `json:"image"`
|
||||
}
|
||||
|
||||
// WeComAccessTokenResponse represents the access token API response
|
||||
type WeComAccessTokenResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// WeComSendMessageResponse represents the send message API response
|
||||
type WeComSendMessageResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
InvalidUser string `json:"invaliduser"`
|
||||
InvalidParty string `json:"invalidparty"`
|
||||
InvalidTag string `json:"invalidtag"`
|
||||
}
|
||||
|
||||
// PKCS7Padding adds PKCS7 padding
|
||||
type PKCS7Padding struct{}
|
||||
|
||||
// NewWeComAppChannel creates a new WeCom App channel instance
|
||||
func NewWeComAppChannel(cfg config.WeComAppConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
||||
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
|
||||
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||
}
|
||||
|
||||
base := NewBaseChannel("wecom_app", cfg, messagesCfg, messageBus, []string(cfg.AllowFrom))
|
||||
|
||||
return &WeComAppChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
processedMsgs: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the channel name
|
||||
func (c *WeComAppChannel) Name() string {
|
||||
return "wecom_app"
|
||||
}
|
||||
|
||||
// Start initializes the WeCom App channel with HTTP webhook server
|
||||
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Get initial access token
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Start token refresh goroutine
|
||||
go c.tokenRefreshLoop()
|
||||
|
||||
// Setup HTTP server for webhook
|
||||
mux := http.NewServeMux()
|
||||
webhookPath := c.config.WebhookPath
|
||||
if webhookPath == "" {
|
||||
webhookPath = "/webhook/wecom-app"
|
||||
}
|
||||
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||
|
||||
// Health check endpoint
|
||||
mux.HandleFunc("/health/wecom-app", c.handleHealth)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||
c.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
|
||||
"address": addr,
|
||||
"path": webhookPath,
|
||||
})
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the WeCom App channel
|
||||
func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("wecom_app", "Stopping WeCom App channel...")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
if c.server != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
c.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to WeCom user proactively using access token
|
||||
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("wecom_app channel not running")
|
||||
}
|
||||
|
||||
accessToken := c.getAccessToken()
|
||||
if accessToken == "" {
|
||||
return fmt.Errorf("no valid access token available")
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Sending message", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
||||
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
||||
}
|
||||
|
||||
// handleWebhook handles incoming webhook requests from WeCom
|
||||
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Log all incoming requests for debugging
|
||||
logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
|
||||
"method": r.Method,
|
||||
"url": r.URL.String(),
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
})
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// Handle verification request
|
||||
c.handleVerification(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Handle message callback
|
||||
c.handleMessageCallback(ctx, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
|
||||
"method": r.Method,
|
||||
})
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// handleVerification handles the URL verification request from WeCom
|
||||
func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
echostr := query.Get("echostr")
|
||||
|
||||
logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"echostr": echostr,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||
logger.ErrorC("wecom_app", "Missing parameters in verification request")
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
||||
"token": c.config.Token,
|
||||
"msg_signature": msgSignature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
})
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugC("wecom_app", "Signature verification passed")
|
||||
|
||||
// Decrypt echostr with CorpID verification
|
||||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
|
||||
"encoding_aes_key": c.config.EncodingAESKey,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
||||
"error": err.Error(),
|
||||
"encoding_aes_key": c.config.EncodingAESKey,
|
||||
"corp_id": c.config.CorpID,
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
|
||||
"decrypted": decryptedEchoStr,
|
||||
})
|
||||
|
||||
// Remove BOM and whitespace as per WeCom documentation
|
||||
// The response must be plain text without quotes, BOM, or newlines
|
||||
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||
w.Write([]byte(decryptedEchoStr))
|
||||
}
|
||||
|
||||
// handleMessageCallback handles incoming messages from WeCom
|
||||
func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
msgSignature := query.Get("msg_signature")
|
||||
timestamp := query.Get("timestamp")
|
||||
nonce := query.Get("nonce")
|
||||
|
||||
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse XML to get encrypted message
|
||||
var encryptedMsg struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
AgentID string `xml:"AgentID"`
|
||||
}
|
||||
|
||||
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt message with CorpID verification
|
||||
// For WeCom App (自建应用), receiveid should be corp_id
|
||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted XML message
|
||||
var msg WeComXMLMessage
|
||||
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the message with context
|
||||
go c.processMessage(ctx, msg)
|
||||
|
||||
// Return success response immediately
|
||||
// WeCom App requires response within configured timeout (default 5 seconds)
|
||||
w.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// processMessage processes the received message
|
||||
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||
// Skip non-text messages for now (can be extended)
|
||||
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
|
||||
"msg_type": msg.MsgType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||
// As per WeCom documentation, use msg_id for deduplication
|
||||
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||
c.msgMu.Lock()
|
||||
if c.processedMsgs[msgID] {
|
||||
c.msgMu.Unlock()
|
||||
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
|
||||
"msg_id": msgID,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.processedMsgs[msgID] = true
|
||||
c.msgMu.Unlock()
|
||||
|
||||
// Clean up old messages periodically (keep last 1000)
|
||||
if len(c.processedMsgs) > 1000 {
|
||||
c.msgMu.Lock()
|
||||
c.processedMsgs = make(map[string]bool)
|
||||
c.msgMu.Unlock()
|
||||
}
|
||||
|
||||
senderID := msg.FromUserName
|
||||
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
|
||||
|
||||
// Build metadata
|
||||
// WeCom App only supports direct messages (private chat)
|
||||
metadata := map[string]string{
|
||||
"msg_type": msg.MsgType,
|
||||
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||
"agent_id": fmt.Sprintf("%d", msg.AgentID),
|
||||
"platform": "wecom_app",
|
||||
"media_id": msg.MediaId,
|
||||
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||
"peer_kind": "direct",
|
||||
"peer_id": senderID,
|
||||
}
|
||||
|
||||
content := msg.Content
|
||||
|
||||
logger.DebugCF("wecom_app", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"msg_type": msg.MsgType,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
}
|
||||
|
||||
// tokenRefreshLoop periodically refreshes the access token
|
||||
func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.refreshAccessToken(); err != nil {
|
||||
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshAccessToken gets a new access token from WeCom API
|
||||
func (c *WeComAppChannel) refreshAccessToken() error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request access token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var tokenResp WeComAccessTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode)
|
||||
}
|
||||
|
||||
c.tokenMu.Lock()
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early
|
||||
c.tokenMu.Unlock()
|
||||
|
||||
logger.DebugC("wecom_app", "Access token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccessToken returns the current valid access token
|
||||
func (c *WeComAppChannel) getAccessToken() string {
|
||||
c.tokenMu.RLock()
|
||||
defer c.tokenMu.RUnlock()
|
||||
|
||||
if time.Now().After(c.tokenExpiry) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.accessToken
|
||||
}
|
||||
|
||||
// sendTextMessage sends a text message to a user
|
||||
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||
|
||||
msg := WeComTextMessage{
|
||||
ToUser: userID,
|
||||
MsgType: "text",
|
||||
AgentID: c.config.AgentID,
|
||||
}
|
||||
msg.Text.Content = content
|
||||
|
||||
jsonData, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// Use configurable timeout (default 5 seconds)
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var sendResp WeComSendMessageResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if sendResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMarkdownMessage sends a markdown message to a user
|
||||
func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||
|
||||
msg := WeComMarkdownMessage{
|
||||
ToUser: userID,
|
||||
MsgType: "markdown",
|
||||
AgentID: c.config.AgentID,
|
||||
}
|
||||
msg.Markdown.Content = content
|
||||
|
||||
jsonData, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// Use configurable timeout (default 5 seconds)
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var sendResp WeComSendMessageResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if sendResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]any{
|
||||
"status": "ok",
|
||||
"running": c.IsRunning(),
|
||||
"has_token": c.getAccessToken() != "",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
1104
pkg/channels/wecom_app_test.go
Normal file
1104
pkg/channels/wecom_app_test.go
Normal file
File diff suppressed because it is too large
Load diff
785
pkg/channels/wecom_test.go
Normal file
785
pkg/channels/wecom_test.go
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// WeCom Bot (企业微信智能机器人) channel tests
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// generateTestAESKey generates a valid test AES key
|
||||
func generateTestAESKey() string {
|
||||
// AES key needs to be 32 bytes (256 bits) for AES-256
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
// Return base64 encoded key without padding
|
||||
return base64.StdEncoding.EncodeToString(key)[:43]
|
||||
}
|
||||
|
||||
// encryptTestMessage encrypts a message for testing (AIBOT JSON format)
|
||||
func encryptTestMessage(message, aesKey string) (string, error) {
|
||||
// Decode AES key
|
||||
key, err := base64.StdEncoding.DecodeString(aesKey + "=")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
||||
random := make([]byte, 0, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
random = append(random, byte(i))
|
||||
}
|
||||
|
||||
msgBytes := []byte(message)
|
||||
receiveID := []byte("test_aibot_id")
|
||||
|
||||
msgLen := uint32(len(msgBytes))
|
||||
lenBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBytes, msgLen)
|
||||
|
||||
plainText := append(random, lenBytes...)
|
||||
plainText = append(plainText, msgBytes...)
|
||||
plainText = append(plainText, receiveID...)
|
||||
|
||||
// PKCS7 padding
|
||||
blockSize := aes.BlockSize
|
||||
padding := blockSize - len(plainText)%blockSize
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
plainText = append(plainText, padText...)
|
||||
|
||||
// Encrypt
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
|
||||
cipherText := make([]byte, len(plainText))
|
||||
mode.CryptBlocks(cipherText, plainText)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
// generateSignature generates a signature for testing
|
||||
func generateSignature(token, timestamp, nonce, msgEncrypt string) string {
|
||||
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||
sort.Strings(params)
|
||||
str := strings.Join(params, "")
|
||||
hash := sha1.Sum([]byte(str))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
func TestNewWeComBotChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing token", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
_, err := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing webhook_url", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "",
|
||||
}
|
||||
_, err := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing webhook_url, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
AllowFrom: []string{"user1", "user2"},
|
||||
}
|
||||
ch, err := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "wecom" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "wecom")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
AllowFrom: []string{},
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
if !ch.IsAllowed("any_user") {
|
||||
t.Error("empty allowlist should allow all users")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
AllowFrom: []string{"allowed_user"},
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
if !ch.IsAllowed("allowed_user") {
|
||||
t.Error("allowed user should pass allowlist check")
|
||||
}
|
||||
if ch.IsAllowed("blocked_user") {
|
||||
t.Error("non-allowed user should be blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotVerifySignature(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
t.Run("valid signature", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
msgEncrypt := "test_message"
|
||||
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||
|
||||
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
||||
t.Error("valid signature should pass verification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
msgEncrypt := "test_message"
|
||||
|
||||
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||
t.Error("invalid signature should fail verification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty token skips verification", func(t *testing.T) {
|
||||
// Create a channel manually with empty token to test the behavior
|
||||
cfgEmpty := config.WeComConfig{
|
||||
Token: "",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
chEmpty := &WeComBotChannel{
|
||||
config: cfgEmpty,
|
||||
}
|
||||
|
||||
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||
t.Error("empty token should skip verification and return true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotDecryptMessage(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
EncodingAESKey: "",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
// Without AES key, message should be base64 decoded only
|
||||
plainText := "hello world"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||
|
||||
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != plainText {
|
||||
t.Errorf("decryptMessage() = %q, want %q", result, plainText)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
EncodingAESKey: aesKey,
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||
encrypted, err := encryptTestMessage(originalMsg, aesKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt test message: %v", err)
|
||||
}
|
||||
|
||||
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != originalMsg {
|
||||
t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid base64", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
EncodingAESKey: "",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid base64, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid AES key", func(t *testing.T) {
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
EncodingAESKey: "invalid_key",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid AES key, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotPKCS7Unpad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: []byte{},
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "valid padding 3 bytes",
|
||||
input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...),
|
||||
expected: []byte("hello"),
|
||||
},
|
||||
{
|
||||
name: "valid padding 16 bytes (full block)",
|
||||
input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...),
|
||||
expected: []byte("123456789012345"),
|
||||
},
|
||||
{
|
||||
name: "invalid padding larger than data",
|
||||
input: []byte{20},
|
||||
expected: nil, // should return error
|
||||
},
|
||||
{
|
||||
name: "invalid padding zero",
|
||||
input: append([]byte("test"), byte(0)),
|
||||
expected: nil, // should return error
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := pkcs7UnpadWeCom(tt.input)
|
||||
if tt.expected == nil {
|
||||
// This case should return an error
|
||||
if err == nil {
|
||||
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotHandleVerification(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
EncodingAESKey: aesKey,
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
t.Run("valid verification request", func(t *testing.T) {
|
||||
echostr := "test_echostr_123"
|
||||
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != echostr {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
echostr := "test_echostr"
|
||||
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleVerification(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
aesKey := generateTestAESKey()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
EncodingAESKey: aesKey,
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
t.Run("valid direct message callback", func(t *testing.T) {
|
||||
// Create JSON message for direct chat (single)
|
||||
jsonMsg := `{
|
||||
"msgid": "test_msg_id_123",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chattype": "single",
|
||||
"from": {"userid": "user123"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello World"}
|
||||
}`
|
||||
|
||||
// Encrypt message
|
||||
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
|
||||
|
||||
// Create encrypted XML wrapper
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: encrypted,
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != "success" {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid group message callback", func(t *testing.T) {
|
||||
// Create JSON message for group chat
|
||||
jsonMsg := `{
|
||||
"msgid": "test_msg_id_456",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chatid": "group_chat_id_123",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user456"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello Group"}
|
||||
}`
|
||||
|
||||
// Encrypt message
|
||||
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
|
||||
|
||||
// Create encrypted XML wrapper
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: encrypted,
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.String() != "success" {
|
||||
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid XML", func(t *testing.T) {
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, "")
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
strings.NewReader("invalid xml"),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: "encrypted_data",
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleMessageCallback(context.Background(), w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotProcessMessage(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
t.Run("process direct text message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_123",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
msg.Text.Content = "Hello World"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("process group text message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_456",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatID: "group_chat_id_123",
|
||||
ChatType: "group",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.From.UserID = "user456"
|
||||
msg.Text.Content = "Hello Group"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("process voice message", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_789",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "voice",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
msg.Voice.Content = "Voice message text"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
|
||||
t.Run("skip unsupported message type", func(t *testing.T) {
|
||||
msg := WeComBotMessage{
|
||||
MsgID: "test_msg_id_000",
|
||||
AIBotID: "test_aibot_id",
|
||||
ChatType: "single",
|
||||
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
MsgType: "video",
|
||||
}
|
||||
msg.From.UserID = "user123"
|
||||
|
||||
// Should not panic
|
||||
ch.processMessage(context.Background(), msg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleWebhook(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
t.Run("GET request calls verification", func(t *testing.T) {
|
||||
echostr := "test_echostr"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
|
||||
nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST request calls message callback", func(t *testing.T) {
|
||||
encryptedWrapper := struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
}{
|
||||
Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
|
||||
}
|
||||
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||
|
||||
timestamp := "1234567890"
|
||||
nonce := "test_nonce"
|
||||
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce,
|
||||
bytes.NewReader(wrapperData),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
// Should not be method not allowed
|
||||
if w.Code == http.StatusMethodNotAllowed {
|
||||
t.Error("POST request should not return Method Not Allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported method", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleWebhook(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWeComBotHandleHealth(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.WeComConfig{
|
||||
Token: "test_token",
|
||||
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
}
|
||||
ch, _ := NewWeComBotChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ch.handleHealth(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
|
||||
t.Errorf("response body should contain status and running fields, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotReplyMessage(t *testing.T) {
|
||||
msg := WeComBotReplyMessage{
|
||||
MsgType: "text",
|
||||
}
|
||||
msg.Text.Content = "Hello World"
|
||||
|
||||
if msg.MsgType != "text" {
|
||||
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||
}
|
||||
if msg.Text.Content != "Hello World" {
|
||||
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComBotMessageStructure(t *testing.T) {
|
||||
jsonData := `{
|
||||
"msgid": "test_msg_id_123",
|
||||
"aibotid": "test_aibot_id",
|
||||
"chatid": "group_chat_id_123",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "user123"},
|
||||
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||
"msgtype": "text",
|
||||
"text": {"content": "Hello World"}
|
||||
}`
|
||||
|
||||
var msg WeComBotMessage
|
||||
err := json.Unmarshal([]byte(jsonData), &msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
if msg.MsgID != "test_msg_id_123" {
|
||||
t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
|
||||
}
|
||||
if msg.AIBotID != "test_aibot_id" {
|
||||
t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
|
||||
}
|
||||
if msg.ChatID != "group_chat_id_123" {
|
||||
t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
|
||||
}
|
||||
if msg.ChatType != "group" {
|
||||
t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
|
||||
}
|
||||
if msg.From.UserID != "user123" {
|
||||
t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
|
||||
}
|
||||
if msg.MsgType != "text" {
|
||||
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||
}
|
||||
if msg.Text.Content != "Hello World" {
|
||||
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return fmt.Errorf("whatsapp connection not established")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"type": "message",
|
||||
"to": msg.ChatID,
|
||||
"content": msg.Content,
|
||||
|
|
@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
|
||||
continue
|
||||
|
|
@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
||||
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
|
||||
senderID, ok := msg["from"].(string)
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
|||
}
|
||||
|
||||
var mediaPaths []string
|
||||
if mediaData, ok := msg["media"].([]interface{}); ok {
|
||||
if mediaData, ok := msg["media"].([]any); ok {
|
||||
mediaPaths = make([]string, 0, len(mediaData))
|
||||
for _, m := range mediaData {
|
||||
if path, ok := m.(string); ok {
|
||||
|
|
@ -178,6 +178,14 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
|||
metadata["user_name"] = userName
|
||||
}
|
||||
|
||||
if chatID == senderID {
|
||||
metadata["peer_kind"] = "direct"
|
||||
metadata["peer_id"] = senderID
|
||||
} else {
|
||||
metadata["peer_kind"] = "group"
|
||||
metadata["peer_id"] = chatID
|
||||
}
|
||||
|
||||
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
// rrCounter is a global counter for round-robin load balancing across models.
|
||||
var rrCounter atomic.Uint64
|
||||
|
||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||
// so allow_from can contain both "123" and 123.
|
||||
type FlexibleStringSlice []string
|
||||
|
|
@ -23,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []interface{}
|
||||
var raw []any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -45,28 +48,136 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
|||
|
||||
type Config struct {
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||
Session SessionConfig `json:"session,omitempty"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers"`
|
||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
Messages MessagesConfig `json:"messages"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for Config
|
||||
// to omit providers section when empty and session when empty
|
||||
func (c Config) MarshalJSON() ([]byte, error) {
|
||||
type Alias Config
|
||||
aux := &struct {
|
||||
Providers *ProvidersConfig `json:"providers,omitempty"`
|
||||
Session *SessionConfig `json:"session,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(&c),
|
||||
}
|
||||
|
||||
// Only include providers if not empty
|
||||
if !c.Providers.IsEmpty() {
|
||||
aux.Providers = &c.Providers
|
||||
}
|
||||
|
||||
// Only include session if not empty
|
||||
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
|
||||
aux.Session = &c.Session
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
type AgentsConfig struct {
|
||||
Defaults AgentDefaults `json:"defaults"`
|
||||
List []AgentConfig `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// AgentModelConfig supports both string and structured model config.
|
||||
// String format: "gpt-4" (just primary, no fallbacks)
|
||||
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
|
||||
type AgentModelConfig struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||
}
|
||||
|
||||
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
m.Primary = s
|
||||
m.Fallbacks = nil
|
||||
return nil
|
||||
}
|
||||
type raw struct {
|
||||
Primary string `json:"primary"`
|
||||
Fallbacks []string `json:"fallbacks"`
|
||||
}
|
||||
var r raw
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Primary = r.Primary
|
||||
m.Fallbacks = r.Fallbacks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
|
||||
if len(m.Fallbacks) == 0 && m.Primary != "" {
|
||||
return json.Marshal(m.Primary)
|
||||
}
|
||||
type raw struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||
}
|
||||
return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
ID string `json:"id"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
Model *AgentModelConfig `json:"model,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
||||
}
|
||||
|
||||
type SubagentsConfig struct {
|
||||
AllowAgents []string `json:"allow_agents,omitempty"`
|
||||
Model *AgentModelConfig `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type PeerMatch struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type BindingMatch struct {
|
||||
Channel string `json:"channel"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Peer *PeerMatch `json:"peer,omitempty"`
|
||||
GuildID string `json:"guild_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
type AgentBinding struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Match BindingMatch `json:"match"`
|
||||
}
|
||||
|
||||
type SessionConfig struct {
|
||||
DMScope string `json:"dm_scope,omitempty"`
|
||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||
}
|
||||
|
||||
type AgentDefaults struct {
|
||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
|
|
@ -80,90 +191,119 @@ type ChannelsConfig struct {
|
|||
Slack SlackConfig `json:"slack"`
|
||||
LINE LINEConfig `json:"line"`
|
||||
OneBot OneBotConfig `json:"onebot"`
|
||||
WeCom WeComConfig `json:"wecom"`
|
||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type FeishuConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||
}
|
||||
|
||||
type MaixCamConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
|
||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
|
||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type QQConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type DingTalkConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type SlackConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type LINEConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type OneBotConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type WeComConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||
}
|
||||
|
||||
type WeComAppConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
|
||||
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
|
||||
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
}
|
||||
|
||||
type DevicesConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
|
||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
||||
}
|
||||
|
||||
|
|
@ -176,27 +316,104 @@ type MessagesConfig struct {
|
|||
}
|
||||
|
||||
type ProvidersConfig struct {
|
||||
Anthropic ProviderConfig `json:"anthropic"`
|
||||
OpenAI ProviderConfig `json:"openai"`
|
||||
OpenRouter ProviderConfig `json:"openrouter"`
|
||||
Groq ProviderConfig `json:"groq"`
|
||||
Zhipu ProviderConfig `json:"zhipu"`
|
||||
VLLM ProviderConfig `json:"vllm"`
|
||||
Gemini ProviderConfig `json:"gemini"`
|
||||
Nvidia ProviderConfig `json:"nvidia"`
|
||||
Ollama ProviderConfig `json:"ollama"`
|
||||
Moonshot ProviderConfig `json:"moonshot"`
|
||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||
DeepSeek ProviderConfig `json:"deepseek"`
|
||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||
Anthropic ProviderConfig `json:"anthropic"`
|
||||
OpenAI OpenAIProviderConfig `json:"openai"`
|
||||
OpenRouter ProviderConfig `json:"openrouter"`
|
||||
Groq ProviderConfig `json:"groq"`
|
||||
Zhipu ProviderConfig `json:"zhipu"`
|
||||
VLLM ProviderConfig `json:"vllm"`
|
||||
Gemini ProviderConfig `json:"gemini"`
|
||||
Nvidia ProviderConfig `json:"nvidia"`
|
||||
Ollama ProviderConfig `json:"ollama"`
|
||||
Moonshot ProviderConfig `json:"moonshot"`
|
||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||
DeepSeek ProviderConfig `json:"deepseek"`
|
||||
Cerebras ProviderConfig `json:"cerebras"`
|
||||
VolcEngine ProviderConfig `json:"volcengine"`
|
||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||
Antigravity ProviderConfig `json:"antigravity"`
|
||||
Qwen ProviderConfig `json:"qwen"`
|
||||
}
|
||||
|
||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
|
||||
func (p ProvidersConfig) IsEmpty() bool {
|
||||
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
||||
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
||||
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
|
||||
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
|
||||
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
|
||||
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
|
||||
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
|
||||
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
|
||||
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
|
||||
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
|
||||
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
|
||||
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
|
||||
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
|
||||
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||
// to omit the entire section when empty
|
||||
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
|
||||
if p.IsEmpty() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type Alias ProvidersConfig
|
||||
return json.Marshal((*Alias)(&p))
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
|
||||
}
|
||||
|
||||
type OpenAIProviderConfig struct {
|
||||
ProviderConfig
|
||||
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
||||
}
|
||||
|
||||
// ModelConfig represents a model-centric provider configuration.
|
||||
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
|
||||
// The model field uses protocol prefix format: [protocol/]model-identifier
|
||||
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||
// Default protocol is "openai" if no prefix is specified.
|
||||
type ModelConfig struct {
|
||||
// Required fields
|
||||
ModelName string `json:"model_name"` // User-facing alias for the model
|
||||
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
|
||||
|
||||
// HTTP-based providers
|
||||
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
||||
APIKey string `json:"api_key"` // API authentication key
|
||||
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
|
||||
|
||||
// Special providers (CLI-based, OAuth, etc.)
|
||||
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
|
||||
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
|
||||
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
|
||||
|
||||
// Optional optimizations
|
||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||
}
|
||||
|
||||
// Validate checks if the ModelConfig has all required fields.
|
||||
func (c *ModelConfig) Validate() error {
|
||||
if c.ModelName == "" {
|
||||
return fmt.Errorf("model_name is required")
|
||||
}
|
||||
if c.Model == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
|
|
@ -205,146 +422,69 @@ type GatewayConfig struct {
|
|||
}
|
||||
|
||||
type BraveConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type DuckDuckGoConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type PerplexityConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type WebToolsConfig struct {
|
||||
Brave BraveConfig `json:"brave"`
|
||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||
Perplexity PerplexityConfig `json:"perplexity"`
|
||||
}
|
||||
|
||||
type CronToolsConfig struct {
|
||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
||||
}
|
||||
|
||||
type ExecConfig struct {
|
||||
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Cron CronToolsConfig `json:"cron"`
|
||||
Exec ExecConfig `json:"exec"`
|
||||
Skills SkillsToolsConfig `json:"skills"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
Model: "glm-4.7",
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
MaxToolIterations: 20,
|
||||
},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: false,
|
||||
BridgeURL: "ws://localhost:3001",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
QQ: QQConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
DingTalk: DingTalkConfig{
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Slack: SlackConfig{
|
||||
Enabled: false,
|
||||
BotToken: "",
|
||||
AppToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
ChannelSecret: "",
|
||||
ChannelAccessToken: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
OneBot: OneBotConfig{
|
||||
Enabled: false,
|
||||
WSUrl: "ws://127.0.0.1:3001",
|
||||
AccessToken: "",
|
||||
ReconnectInterval: 5,
|
||||
GroupTriggerPrefix: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Anthropic: ProviderConfig{},
|
||||
OpenAI: ProviderConfig{},
|
||||
OpenRouter: ProviderConfig{},
|
||||
Groq: ProviderConfig{},
|
||||
Zhipu: ProviderConfig{},
|
||||
VLLM: ProviderConfig{},
|
||||
Gemini: ProviderConfig{},
|
||||
Nvidia: ProviderConfig{},
|
||||
Moonshot: ProviderConfig{},
|
||||
ShengSuanYun: ProviderConfig{},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
Web: WebToolsConfig{
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
DuckDuckGo: DuckDuckGoConfig{
|
||||
Enabled: true,
|
||||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
Interval: 30, // default 30 minutes
|
||||
},
|
||||
Devices: DevicesConfig{
|
||||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
Messages: MessagesConfig{
|
||||
AckReaction: "",
|
||||
AckReactionScope: "group-mentions",
|
||||
},
|
||||
}
|
||||
type SkillsToolsConfig struct {
|
||||
Registries SkillsRegistriesConfig `json:"registries"`
|
||||
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||
SearchCache SearchCacheConfig `json:"search_cache"`
|
||||
}
|
||||
|
||||
type SearchCacheConfig struct {
|
||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||
}
|
||||
|
||||
type SkillsRegistriesConfig struct {
|
||||
ClawHub ClawHubRegistryConfig `json:"clawhub"`
|
||||
}
|
||||
|
||||
type ClawHubRegistryConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
|
||||
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
|
||||
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
|
||||
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
|
||||
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
|
||||
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
|
||||
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
|
||||
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
|
|
@ -366,35 +506,38 @@ func LoadConfig(path string) (*Config, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
||||
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
||||
cfg.ModelList = ConvertProvidersToModelList(cfg)
|
||||
}
|
||||
|
||||
// Validate model_list for uniqueness and required fields
|
||||
if err := cfg.ValidateModelList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveConfig(path string, cfg *Config) error {
|
||||
cfg.mu.RLock()
|
||||
defer cfg.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0600)
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Config) WorkspacePath() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return expandHome(c.Agents.Defaults.Workspace)
|
||||
}
|
||||
|
||||
func (c *Config) GetAPIKey() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.Providers.OpenRouter.APIKey != "" {
|
||||
return c.Providers.OpenRouter.APIKey
|
||||
}
|
||||
|
|
@ -419,12 +562,13 @@ func (c *Config) GetAPIKey() string {
|
|||
if c.Providers.ShengSuanYun.APIKey != "" {
|
||||
return c.Providers.ShengSuanYun.APIKey
|
||||
}
|
||||
if c.Providers.Cerebras.APIKey != "" {
|
||||
return c.Providers.Cerebras.APIKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Config) GetAPIBase() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.Providers.OpenRouter.APIKey != "" {
|
||||
if c.Providers.OpenRouter.APIBase != "" {
|
||||
return c.Providers.OpenRouter.APIBase
|
||||
|
|
@ -453,3 +597,65 @@ func expandHome(path string) string {
|
|||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GetModelConfig returns the ModelConfig for the given model name.
|
||||
// If multiple configs exist with the same model_name, it uses round-robin
|
||||
// selection for load balancing. Returns an error if the model is not found.
|
||||
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||
matches := c.findMatches(modelName)
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return &matches[0], nil
|
||||
}
|
||||
|
||||
// Multiple configs - use round-robin for load balancing
|
||||
idx := rrCounter.Add(1) % uint64(len(matches))
|
||||
return &matches[idx], nil
|
||||
}
|
||||
|
||||
// findMatches finds all ModelConfig entries with the given model_name.
|
||||
func (c *Config) findMatches(modelName string) []ModelConfig {
|
||||
var matches []ModelConfig
|
||||
for i := range c.ModelList {
|
||||
if c.ModelList[i].ModelName == modelName {
|
||||
matches = append(matches, c.ModelList[i])
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// HasProvidersConfig checks if any provider in the old providers config has configuration.
|
||||
func (c *Config) HasProvidersConfig() bool {
|
||||
v := c.Providers
|
||||
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
|
||||
v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
|
||||
v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
|
||||
v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
|
||||
v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
|
||||
v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
|
||||
v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
|
||||
v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
|
||||
v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
|
||||
v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
|
||||
v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
|
||||
v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
|
||||
v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
|
||||
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
||||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
||||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
||||
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
||||
}
|
||||
|
||||
// ValidateModelList validates all ModelConfig entries in the model_list.
|
||||
// It checks that each model config is valid.
|
||||
// Note: Multiple entries with the same model_name are allowed for load balancing.
|
||||
func (c *Config) ValidateModelList() error {
|
||||
for i := range c.ModelList {
|
||||
if err := c.ModelList[i].Validate(); err != nil {
|
||||
return fmt.Errorf("model_list[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,193 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
||||
var m AgentModelConfig
|
||||
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
||||
t.Fatalf("unmarshal string: %v", err)
|
||||
}
|
||||
if m.Primary != "gpt-4" {
|
||||
t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
|
||||
}
|
||||
if m.Fallbacks != nil {
|
||||
t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
|
||||
var m AgentModelConfig
|
||||
data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
|
||||
if err := json.Unmarshal([]byte(data), &m); err != nil {
|
||||
t.Fatalf("unmarshal object: %v", err)
|
||||
}
|
||||
if m.Primary != "claude-opus" {
|
||||
t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
|
||||
}
|
||||
if len(m.Fallbacks) != 2 {
|
||||
t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
|
||||
}
|
||||
if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
|
||||
t.Errorf("Fallbacks = %v", m.Fallbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentModelConfig_MarshalString(t *testing.T) {
|
||||
m := AgentModelConfig{Primary: "gpt-4"}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if string(data) != `"gpt-4"` {
|
||||
t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||
m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
json.Unmarshal(data, &result)
|
||||
if result["primary"] != "claude-opus" {
|
||||
t.Errorf("primary = %v", result["primary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_FullParse(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"max_tool_iterations": 20
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "sales",
|
||||
"default": true,
|
||||
"name": "Sales Bot",
|
||||
"model": "gpt-4"
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support Bot",
|
||||
"model": {
|
||||
"primary": "claude-opus",
|
||||
"fallbacks": ["haiku"]
|
||||
},
|
||||
"subagents": {
|
||||
"allow_agents": ["sales"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"agent_id": "support",
|
||||
"match": {
|
||||
"channel": "telegram",
|
||||
"account_id": "*",
|
||||
"peer": {"kind": "direct", "id": "user123"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"session": {
|
||||
"dm_scope": "per-peer",
|
||||
"identity_links": {
|
||||
"john": ["telegram:123", "discord:john#1234"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Agents.List) != 2 {
|
||||
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
|
||||
}
|
||||
|
||||
sales := cfg.Agents.List[0]
|
||||
if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
|
||||
t.Errorf("sales = %+v", sales)
|
||||
}
|
||||
if sales.Model == nil || sales.Model.Primary != "gpt-4" {
|
||||
t.Errorf("sales.Model = %+v", sales.Model)
|
||||
}
|
||||
|
||||
support := cfg.Agents.List[1]
|
||||
if support.ID != "support" || support.Name != "Support Bot" {
|
||||
t.Errorf("support = %+v", support)
|
||||
}
|
||||
if support.Model == nil || support.Model.Primary != "claude-opus" {
|
||||
t.Errorf("support.Model = %+v", support.Model)
|
||||
}
|
||||
if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
|
||||
t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
|
||||
}
|
||||
if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
|
||||
t.Errorf("support.Subagents = %+v", support.Subagents)
|
||||
}
|
||||
|
||||
if len(cfg.Bindings) != 1 {
|
||||
t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings))
|
||||
}
|
||||
binding := cfg.Bindings[0]
|
||||
if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
|
||||
t.Errorf("binding = %+v", binding)
|
||||
}
|
||||
if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" {
|
||||
t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer)
|
||||
}
|
||||
|
||||
if cfg.Session.DMScope != "per-peer" {
|
||||
t.Errorf("Session.DMScope = %q", cfg.Session.DMScope)
|
||||
}
|
||||
if len(cfg.Session.IdentityLinks) != 1 {
|
||||
t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
|
||||
}
|
||||
links := cfg.Session.IdentityLinks["john"]
|
||||
if len(links) != 2 {
|
||||
t.Errorf("john links = %v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Agents.List) != 0 {
|
||||
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
|
||||
}
|
||||
if len(cfg.Bindings) != 0 {
|
||||
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
|
||||
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
|
@ -20,8 +201,6 @@ func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
|||
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Just verify the workspace is set, don't compare exact paths
|
||||
// since expandHome behavior may differ based on environment
|
||||
if cfg.Agents.Defaults.Workspace == "" {
|
||||
t.Error("Workspace should not be empty")
|
||||
}
|
||||
|
|
@ -58,8 +237,8 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
|||
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Temperature == 0 {
|
||||
t.Error("Temperature should not be zero")
|
||||
if cfg.Agents.Defaults.Temperature != nil {
|
||||
t.Error("Temperature should be nil when not provided")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +258,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
|||
func TestDefaultConfig_Providers(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Verify all providers are empty by default
|
||||
if cfg.Providers.Anthropic.APIKey != "" {
|
||||
t.Error("Anthropic API key should be empty by default")
|
||||
}
|
||||
|
|
@ -89,46 +267,18 @@ func TestDefaultConfig_Providers(t *testing.T) {
|
|||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
t.Error("OpenRouter API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
t.Error("Groq API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.Zhipu.APIKey != "" {
|
||||
t.Error("Zhipu API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.VLLM.APIKey != "" {
|
||||
t.Error("VLLM API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.Gemini.APIKey != "" {
|
||||
t.Error("Gemini API key should be empty by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Channels verifies channels are disabled by default
|
||||
func TestDefaultConfig_Channels(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Verify all channels are disabled by default
|
||||
if cfg.Channels.WhatsApp.Enabled {
|
||||
t.Error("WhatsApp should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Feishu.Enabled {
|
||||
t.Error("Feishu should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Discord.Enabled {
|
||||
t.Error("Discord should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.MaixCam.Enabled {
|
||||
t.Error("MaixCam should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.QQ.Enabled {
|
||||
t.Error("QQ should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.DingTalk.Enabled {
|
||||
t.Error("DingTalk should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Slack.Enabled {
|
||||
t.Error("Slack should be disabled by default")
|
||||
}
|
||||
|
|
@ -169,7 +319,7 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
|||
}
|
||||
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("config file has permission %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
@ -178,15 +328,14 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
|||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Verify complete config structure
|
||||
if cfg.Agents.Defaults.Workspace == "" {
|
||||
t.Error("Workspace should not be empty")
|
||||
}
|
||||
if cfg.Agents.Defaults.Model == "" {
|
||||
t.Error("Model should not be empty")
|
||||
}
|
||||
if cfg.Agents.Defaults.Temperature == 0 {
|
||||
t.Error("Temperature should have default value")
|
||||
if cfg.Agents.Defaults.Temperature != nil {
|
||||
t.Error("Temperature should be nil when not provided")
|
||||
}
|
||||
if cfg.Agents.Defaults.MaxTokens == 0 {
|
||||
t.Error("MaxTokens should not be zero")
|
||||
|
|
@ -204,3 +353,42 @@ func TestConfig_Complete(t *testing.T) {
|
|||
t.Error("Heartbeat should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if !cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("OpenAI codex web search should remain true when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if cfg.Providers.OpenAI.WebSearch {
|
||||
t.Fatal("OpenAI codex web search should be false when disabled in config file")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
320
pkg/config/defaults.go
Normal file
320
pkg/config/defaults.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
// DefaultConfig returns the default configuration for PicoClaw.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
Model: "glm-4.7",
|
||||
MaxTokens: 8192,
|
||||
Temperature: nil, // nil means use provider default
|
||||
MaxToolIterations: 20,
|
||||
},
|
||||
},
|
||||
Bindings: []AgentBinding{},
|
||||
Session: SessionConfig{
|
||||
DMScope: "main",
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: false,
|
||||
BridgeURL: "ws://localhost:3001",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
MentionOnly: false,
|
||||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
QQ: QQConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
DingTalk: DingTalkConfig{
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Slack: SlackConfig{
|
||||
Enabled: false,
|
||||
BotToken: "",
|
||||
AppToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
ChannelSecret: "",
|
||||
ChannelAccessToken: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
OneBot: OneBotConfig{
|
||||
Enabled: false,
|
||||
WSUrl: "ws://127.0.0.1:3001",
|
||||
AccessToken: "",
|
||||
ReconnectInterval: 5,
|
||||
GroupTriggerPrefix: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
WeCom: WeComConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
EncodingAESKey: "",
|
||||
WebhookURL: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18793,
|
||||
WebhookPath: "/webhook/wecom",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
WeComApp: WeComAppConfig{
|
||||
Enabled: false,
|
||||
CorpID: "",
|
||||
CorpSecret: "",
|
||||
AgentID: 0,
|
||||
Token: "",
|
||||
EncodingAESKey: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18792,
|
||||
WebhookPath: "/webhook/wecom-app",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
ReplyTimeout: 5,
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
},
|
||||
ModelList: []ModelConfig{
|
||||
// ============================================
|
||||
// Add your API key to the model you want to use
|
||||
// ============================================
|
||||
|
||||
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
|
||||
{
|
||||
ModelName: "glm-4.7",
|
||||
Model: "zhipu/glm-4.7",
|
||||
APIBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// OpenAI - https://platform.openai.com/api-keys
|
||||
{
|
||||
ModelName: "gpt-5.2",
|
||||
Model: "openai/gpt-5.2",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Anthropic Claude - https://console.anthropic.com/settings/keys
|
||||
{
|
||||
ModelName: "claude-sonnet-4.6",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
APIBase: "https://api.anthropic.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// DeepSeek - https://platform.deepseek.com/
|
||||
{
|
||||
ModelName: "deepseek-chat",
|
||||
Model: "deepseek/deepseek-chat",
|
||||
APIBase: "https://api.deepseek.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Google Gemini - https://ai.google.dev/
|
||||
{
|
||||
ModelName: "gemini-2.0-flash",
|
||||
Model: "gemini/gemini-2.0-flash-exp",
|
||||
APIBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
||||
{
|
||||
ModelName: "qwen-plus",
|
||||
Model: "qwen/qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||
{
|
||||
ModelName: "moonshot-v1-8k",
|
||||
Model: "moonshot/moonshot-v1-8k",
|
||||
APIBase: "https://api.moonshot.cn/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Groq - https://console.groq.com/keys
|
||||
{
|
||||
ModelName: "llama-3.3-70b",
|
||||
Model: "groq/llama-3.3-70b-versatile",
|
||||
APIBase: "https://api.groq.com/openai/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// OpenRouter (100+ models) - https://openrouter.ai/keys
|
||||
{
|
||||
ModelName: "openrouter-auto",
|
||||
Model: "openrouter/auto",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
{
|
||||
ModelName: "openrouter-gpt-5.2",
|
||||
Model: "openrouter/openai/gpt-5.2",
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// NVIDIA - https://build.nvidia.com/
|
||||
{
|
||||
ModelName: "nemotron-4-340b",
|
||||
Model: "nvidia/nemotron-4-340b-instruct",
|
||||
APIBase: "https://integrate.api.nvidia.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Cerebras - https://inference.cerebras.ai/
|
||||
{
|
||||
ModelName: "cerebras-llama-3.3-70b",
|
||||
Model: "cerebras/llama-3.3-70b",
|
||||
APIBase: "https://api.cerebras.ai/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||
{
|
||||
ModelName: "doubao-pro",
|
||||
Model: "volcengine/doubao-pro-32k",
|
||||
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// ShengsuanYun (神算云)
|
||||
{
|
||||
ModelName: "deepseek-v3",
|
||||
Model: "shengsuanyun/deepseek-v3",
|
||||
APIBase: "https://api.shengsuanyun.com/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
|
||||
// Antigravity (Google Cloud Code Assist) - OAuth only
|
||||
{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
|
||||
// GitHub Copilot - https://github.com/settings/tokens
|
||||
{
|
||||
ModelName: "copilot-gpt-5.2",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
APIBase: "http://localhost:4321",
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
|
||||
// Ollama (local) - https://ollama.com
|
||||
{
|
||||
ModelName: "llama3",
|
||||
Model: "ollama/llama3",
|
||||
APIBase: "http://localhost:11434/v1",
|
||||
APIKey: "ollama",
|
||||
},
|
||||
|
||||
// VLLM (local) - http://localhost:8000
|
||||
{
|
||||
ModelName: "local-model",
|
||||
Model: "vllm/custom-model",
|
||||
APIBase: "http://localhost:8000/v1",
|
||||
APIKey: "",
|
||||
},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
Web: WebToolsConfig{
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
DuckDuckGo: DuckDuckGoConfig{
|
||||
Enabled: true,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Perplexity: PerplexityConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
Cron: CronToolsConfig{
|
||||
ExecTimeoutMinutes: 5,
|
||||
},
|
||||
Exec: ExecConfig{
|
||||
EnableDenyPatterns: true,
|
||||
},
|
||||
Skills: SkillsToolsConfig{
|
||||
Registries: SkillsRegistriesConfig{
|
||||
ClawHub: ClawHubRegistryConfig{
|
||||
Enabled: true,
|
||||
BaseURL: "https://clawhub.ai",
|
||||
},
|
||||
},
|
||||
MaxConcurrentSearches: 2,
|
||||
SearchCache: SearchCacheConfig{
|
||||
MaxSize: 50,
|
||||
TTLSeconds: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
Interval: 30,
|
||||
},
|
||||
Devices: DevicesConfig{
|
||||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
Messages: MessagesConfig{
|
||||
AckReaction: "",
|
||||
AckReactionScope: "group-mentions",
|
||||
},
|
||||
}
|
||||
}
|
||||
353
pkg/config/migration.go
Normal file
353
pkg/config/migration.go
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// buildModelWithProtocol constructs a model string with protocol prefix.
|
||||
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
||||
// Otherwise, the protocol prefix is added.
|
||||
func buildModelWithProtocol(protocol, model string) string {
|
||||
if strings.Contains(model, "/") {
|
||||
// Model already has a protocol prefix, return as-is
|
||||
return model
|
||||
}
|
||||
return protocol + "/" + model
|
||||
}
|
||||
|
||||
// providerMigrationConfig defines how to migrate a provider from old config to new format.
|
||||
type providerMigrationConfig struct {
|
||||
// providerNames are the possible names used in agents.defaults.provider
|
||||
providerNames []string
|
||||
// protocol is the protocol prefix for the model field
|
||||
protocol string
|
||||
// buildConfig creates the ModelConfig from ProviderConfig
|
||||
buildConfig func(p ProvidersConfig) (ModelConfig, bool)
|
||||
}
|
||||
|
||||
// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
|
||||
// This enables backward compatibility with existing configurations.
|
||||
// It preserves the user's configured model from agents.defaults.model when possible.
|
||||
func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get user's configured provider and model
|
||||
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
userModel := cfg.Agents.Defaults.Model
|
||||
|
||||
p := cfg.Providers
|
||||
|
||||
var result []ModelConfig
|
||||
|
||||
// Track if we've applied the legacy model name fix (only for first provider)
|
||||
legacyModelNameApplied := false
|
||||
|
||||
// Define migration rules for each provider
|
||||
migrations := []providerMigrationConfig{
|
||||
{
|
||||
providerNames: []string{"openai", "gpt"},
|
||||
protocol: "openai",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "openai",
|
||||
Model: "openai/gpt-5.2",
|
||||
APIKey: p.OpenAI.APIKey,
|
||||
APIBase: p.OpenAI.APIBase,
|
||||
Proxy: p.OpenAI.Proxy,
|
||||
AuthMethod: p.OpenAI.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"anthropic", "claude"},
|
||||
protocol: "anthropic",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "anthropic",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
APIKey: p.Anthropic.APIKey,
|
||||
APIBase: p.Anthropic.APIBase,
|
||||
Proxy: p.Anthropic.Proxy,
|
||||
AuthMethod: p.Anthropic.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"openrouter"},
|
||||
protocol: "openrouter",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "openrouter",
|
||||
Model: "openrouter/auto",
|
||||
APIKey: p.OpenRouter.APIKey,
|
||||
APIBase: p.OpenRouter.APIBase,
|
||||
Proxy: p.OpenRouter.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"groq"},
|
||||
protocol: "groq",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "groq",
|
||||
Model: "groq/llama-3.1-70b-versatile",
|
||||
APIKey: p.Groq.APIKey,
|
||||
APIBase: p.Groq.APIBase,
|
||||
Proxy: p.Groq.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"zhipu", "glm"},
|
||||
protocol: "zhipu",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "zhipu",
|
||||
Model: "zhipu/glm-4",
|
||||
APIKey: p.Zhipu.APIKey,
|
||||
APIBase: p.Zhipu.APIBase,
|
||||
Proxy: p.Zhipu.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"vllm"},
|
||||
protocol: "vllm",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "vllm",
|
||||
Model: "vllm/auto",
|
||||
APIKey: p.VLLM.APIKey,
|
||||
APIBase: p.VLLM.APIBase,
|
||||
Proxy: p.VLLM.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"gemini", "google"},
|
||||
protocol: "gemini",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "gemini",
|
||||
Model: "gemini/gemini-pro",
|
||||
APIKey: p.Gemini.APIKey,
|
||||
APIBase: p.Gemini.APIBase,
|
||||
Proxy: p.Gemini.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"nvidia"},
|
||||
protocol: "nvidia",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "nvidia",
|
||||
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
||||
APIKey: p.Nvidia.APIKey,
|
||||
APIBase: p.Nvidia.APIBase,
|
||||
Proxy: p.Nvidia.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"ollama"},
|
||||
protocol: "ollama",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "ollama",
|
||||
Model: "ollama/llama3",
|
||||
APIKey: p.Ollama.APIKey,
|
||||
APIBase: p.Ollama.APIBase,
|
||||
Proxy: p.Ollama.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"moonshot", "kimi"},
|
||||
protocol: "moonshot",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "moonshot",
|
||||
Model: "moonshot/kimi",
|
||||
APIKey: p.Moonshot.APIKey,
|
||||
APIBase: p.Moonshot.APIBase,
|
||||
Proxy: p.Moonshot.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"shengsuanyun"},
|
||||
protocol: "shengsuanyun",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "shengsuanyun",
|
||||
Model: "shengsuanyun/auto",
|
||||
APIKey: p.ShengSuanYun.APIKey,
|
||||
APIBase: p.ShengSuanYun.APIBase,
|
||||
Proxy: p.ShengSuanYun.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"deepseek"},
|
||||
protocol: "deepseek",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "deepseek",
|
||||
Model: "deepseek/deepseek-chat",
|
||||
APIKey: p.DeepSeek.APIKey,
|
||||
APIBase: p.DeepSeek.APIBase,
|
||||
Proxy: p.DeepSeek.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"cerebras"},
|
||||
protocol: "cerebras",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "cerebras",
|
||||
Model: "cerebras/llama-3.3-70b",
|
||||
APIKey: p.Cerebras.APIKey,
|
||||
APIBase: p.Cerebras.APIBase,
|
||||
Proxy: p.Cerebras.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"volcengine", "doubao"},
|
||||
protocol: "volcengine",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "volcengine",
|
||||
Model: "volcengine/doubao-pro",
|
||||
APIKey: p.VolcEngine.APIKey,
|
||||
APIBase: p.VolcEngine.APIBase,
|
||||
Proxy: p.VolcEngine.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"github_copilot", "copilot"},
|
||||
protocol: "github-copilot",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "github-copilot",
|
||||
Model: "github-copilot/gpt-5.2",
|
||||
APIBase: p.GitHubCopilot.APIBase,
|
||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"antigravity"},
|
||||
protocol: "antigravity",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "antigravity",
|
||||
Model: "antigravity/gemini-2.0-flash",
|
||||
APIKey: p.Antigravity.APIKey,
|
||||
AuthMethod: p.Antigravity.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"qwen", "tongyi"},
|
||||
protocol: "qwen",
|
||||
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
|
||||
return ModelConfig{}, false
|
||||
}
|
||||
return ModelConfig{
|
||||
ModelName: "qwen",
|
||||
Model: "qwen/qwen-max",
|
||||
APIKey: p.Qwen.APIKey,
|
||||
APIBase: p.Qwen.APIBase,
|
||||
Proxy: p.Qwen.Proxy,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
for _, m := range migrations {
|
||||
mc, ok := m.buildConfig(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the user's configured provider
|
||||
if slices.Contains(m.providerNames, userProvider) && userModel != "" {
|
||||
// Use the user's configured model instead of default
|
||||
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||
} else if userProvider == "" && userModel != "" && !legacyModelNameApplied {
|
||||
// Legacy config: no explicit provider field but model is specified
|
||||
// Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it
|
||||
// This maintains backward compatibility with old configs that relied on implicit provider selection
|
||||
mc.ModelName = userModel
|
||||
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||
legacyModelNameApplied = true
|
||||
}
|
||||
|
||||
result = append(result, mc)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
561
pkg/config/migration_test.go
Normal file
561
pkg/config/migration_test.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
APIKey: "sk-test-key",
|
||||
APIBase: "https://custom.api.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "openai" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||
}
|
||||
if result[0].Model != "openai/gpt-5.2" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
|
||||
}
|
||||
if result[0].APIKey != "sk-test-key" {
|
||||
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
Anthropic: ProviderConfig{
|
||||
APIKey: "ant-key",
|
||||
APIBase: "https://custom.anthropic.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "anthropic" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic")
|
||||
}
|
||||
if result[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
||||
Groq: ProviderConfig{APIKey: "groq-key"},
|
||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("len(result) = %d, want 3", len(result))
|
||||
}
|
||||
|
||||
// Check that all providers are present
|
||||
found := make(map[string]bool)
|
||||
for _, mc := range result {
|
||||
found[mc.ModelName] = true
|
||||
}
|
||||
|
||||
for _, name := range []string{"openai", "groq", "zhipu"} {
|
||||
if !found[name] {
|
||||
t.Errorf("Missing provider %q in result", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Empty(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("len(result) = %d, want 0", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Nil(t *testing.T) {
|
||||
result := ConvertProvidersToModelList(nil)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
|
||||
Anthropic: ProviderConfig{APIKey: "key2"},
|
||||
OpenRouter: ProviderConfig{APIKey: "key3"},
|
||||
Groq: ProviderConfig{APIKey: "key4"},
|
||||
Zhipu: ProviderConfig{APIKey: "key5"},
|
||||
VLLM: ProviderConfig{APIKey: "key6"},
|
||||
Gemini: ProviderConfig{APIKey: "key7"},
|
||||
Nvidia: ProviderConfig{APIKey: "key8"},
|
||||
Ollama: ProviderConfig{APIKey: "key9"},
|
||||
Moonshot: ProviderConfig{APIKey: "key10"},
|
||||
ShengSuanYun: ProviderConfig{APIKey: "key11"},
|
||||
DeepSeek: ProviderConfig{APIKey: "key12"},
|
||||
Cerebras: ProviderConfig{APIKey: "key13"},
|
||||
VolcEngine: ProviderConfig{APIKey: "key14"},
|
||||
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||
Qwen: ProviderConfig{APIKey: "key17"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
// All 17 providers should be converted
|
||||
if len(result) != 17 {
|
||||
t.Errorf("len(result) = %d, want 17", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
APIKey: "key",
|
||||
Proxy: "http://proxy:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Proxy != "http://proxy:8080" {
|
||||
t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for preserving user's configured model during migration
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-reasoner",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use user's model, not default
|
||||
if result[0].Model != "deepseek/deepseek-reasoner" {
|
||||
t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4-turbo",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "openai/gpt-4-turbo" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "claude", // alternative name
|
||||
Model: "claude-opus-4-20250514",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Anthropic: ProviderConfig{APIKey: "sk-ant"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "anthropic/claude-opus-4-20250514" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "qwen",
|
||||
Model: "qwen-plus",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Qwen: ProviderConfig{APIKey: "sk-qwen"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "qwen/qwen-plus" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "deepseek",
|
||||
Model: "", // no model specified
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use default model
|
||||
if result[0].Model != "deepseek/deepseek-chat" {
|
||||
t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-reasoner",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
|
||||
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||
}
|
||||
|
||||
// Find each provider and verify model
|
||||
for _, mc := range result {
|
||||
switch mc.ModelName {
|
||||
case "openai":
|
||||
if mc.Model != "openai/gpt-5.2" {
|
||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
|
||||
}
|
||||
case "deepseek":
|
||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||
t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
providerAlias string
|
||||
expectedModel string
|
||||
provider ProviderConfig
|
||||
}{
|
||||
{"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
|
||||
{"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
|
||||
{"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
|
||||
{"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
|
||||
{"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.providerAlias, func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: tt.providerAlias,
|
||||
Model: strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
),
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{},
|
||||
}
|
||||
|
||||
// Set the appropriate provider config
|
||||
switch tt.providerAlias {
|
||||
case "gpt":
|
||||
cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
|
||||
case "claude":
|
||||
cfg.Providers.Anthropic = tt.provider
|
||||
case "doubao":
|
||||
cfg.Providers.VolcEngine = tt.provider
|
||||
case "tongyi":
|
||||
cfg.Providers.Qwen = tt.provider
|
||||
case "kimi":
|
||||
cfg.Providers.Moonshot = tt.provider
|
||||
}
|
||||
|
||||
// Need to fix the model name in config
|
||||
cfg.Agents.Defaults.Model = strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
)
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Extract just the model ID part (after the first /)
|
||||
expectedModelID := tt.expectedModel
|
||||
if result[0].Model != expectedModelID {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test for backward compatibility: single provider without explicit provider field
|
||||
// This matches the legacy config pattern where users only set model, not provider
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {
|
||||
// This matches the user's actual config:
|
||||
// - No provider field set
|
||||
// - model = "glm-4.7"
|
||||
// - Only zhipu has API key configured
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "", // Not set
|
||||
Model: "glm-4.7",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// ModelName should be the user's model value for backward compatibility
|
||||
if result[0].ModelName != "glm-4.7" {
|
||||
t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7")
|
||||
}
|
||||
|
||||
// Model should use the user's model with protocol prefix
|
||||
if result[0].Model != "zhipu/glm-4.7" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {
|
||||
// When multiple providers are configured but no provider field is set,
|
||||
// the FIRST provider (in migration order) will use userModel as ModelName
|
||||
// for backward compatibility with legacy implicit provider selection
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "", // Not set
|
||||
Model: "some-model",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
|
||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||
}
|
||||
|
||||
// The first provider (OpenAI in migration order) should use userModel as ModelName
|
||||
// This ensures GetModelConfig("some-model") will find it
|
||||
if result[0].ModelName != "some-model" {
|
||||
t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model")
|
||||
}
|
||||
|
||||
// Other providers should use provider name as ModelName
|
||||
if result[1].ModelName != "zhipu" {
|
||||
t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||
// Edge case: no provider, no model
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "",
|
||||
Model: "",
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use default provider name since no model is specified
|
||||
if result[0].ModelName != "zhipu" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu")
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for buildModelWithProtocol helper function
|
||||
|
||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("openai", "gpt-5.2")
|
||||
if result != "openai/gpt-5.2" {
|
||||
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("openrouter", "openrouter/auto")
|
||||
if result != "openrouter/auto" {
|
||||
t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
|
||||
if result != "openrouter/claude-sonnet-4.6" {
|
||||
t.Errorf(
|
||||
"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
|
||||
result,
|
||||
"openrouter/claude-sonnet-4.6",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Test for legacy config with protocol prefix in model name
|
||||
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Provider: "", // No explicit provider
|
||||
Model: "openrouter/auto", // Model already has protocol prefix
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
|
||||
},
|
||||
}
|
||||
|
||||
result := ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) < 1 {
|
||||
t.Fatalf("len(result) = %d, want at least 1", len(result))
|
||||
}
|
||||
|
||||
// First provider should use userModel as ModelName for backward compatibility
|
||||
if result[0].ModelName != "openrouter/auto" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto")
|
||||
}
|
||||
|
||||
// Model should NOT have duplicated prefix
|
||||
if result[0].Model != "openrouter/auto" {
|
||||
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
|
||||
}
|
||||
}
|
||||
235
pkg/config/model_config_test.go
Normal file
235
pkg/config/model_config_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetModelConfig_Found(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||
{ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := cfg.GetModelConfig("test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig() error = %v", err)
|
||||
}
|
||||
if result.Model != "openai/gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfig_NotFound(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cfg.GetModelConfig("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("GetModelConfig() expected error for nonexistent model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfig_EmptyList(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{},
|
||||
}
|
||||
|
||||
_, err := cfg.GetModelConfig("any-model")
|
||||
if err == nil {
|
||||
t.Fatal("GetModelConfig() expected error for empty model list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
||||
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
||||
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test round-robin distribution
|
||||
results := make(map[string]int)
|
||||
for i := 0; i < 30; i++ {
|
||||
result, err := cfg.GetModelConfig("lb-model")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig() error = %v", err)
|
||||
}
|
||||
results[result.Model]++
|
||||
}
|
||||
|
||||
// Each model should appear roughly 10 times (30 calls / 3 models)
|
||||
for model, count := range results {
|
||||
if count < 5 || count > 15 {
|
||||
t.Errorf("Model %s appeared %d times, expected ~10", model, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
||||
},
|
||||
}
|
||||
|
||||
const goroutines = 100
|
||||
const iterations = 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, goroutines*iterations)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
_, err := cfg.GetModelConfig("concurrent-model")
|
||||
if err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
t.Errorf("Concurrent GetModelConfig() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config ModelConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: ModelConfig{
|
||||
ModelName: "test",
|
||||
Model: "openai/gpt-4o",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing model_name",
|
||||
config: ModelConfig{
|
||||
Model: "openai/gpt-4o",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing model",
|
||||
config: ModelConfig{
|
||||
ModelName: "test",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty config",
|
||||
config: ModelConfig{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateModelList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string // partial error message to check
|
||||
}{
|
||||
{
|
||||
name: "valid list",
|
||||
config: &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||
{ModelName: "test2", Model: "anthropic/claude"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid entry",
|
||||
config: &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "test1", Model: "openai/gpt-4o"},
|
||||
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "model_name is required",
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
config: &Config{
|
||||
ModelList: []ModelConfig{},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
// Load balancing: multiple entries with same model_name are allowed
|
||||
name: "duplicate model_name for load balancing",
|
||||
config: &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
|
||||
},
|
||||
},
|
||||
wantErr: false, // Changed: duplicates are allowed for load balancing
|
||||
},
|
||||
{
|
||||
// Load balancing: non-adjacent entries with same model_name are also allowed
|
||||
name: "duplicate model_name non-adjacent for load balancing",
|
||||
config: &Config{
|
||||
ModelList: []ModelConfig{
|
||||
{ModelName: "model-a", Model: "openai/gpt-4o"},
|
||||
{ModelName: "model-b", Model: "anthropic/claude"},
|
||||
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
|
||||
},
|
||||
},
|
||||
wantErr: false, // Changed: duplicates are allowed for load balancing
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.ValidateModelList()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateModelList() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil && tt.errMsg != "" {
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
// Package constants provides shared constants across the codebase.
|
||||
package constants
|
||||
|
||||
// InternalChannels defines channels that are used for internal communication
|
||||
// internalChannels defines channels that are used for internal communication
|
||||
// and should not be exposed to external users or recorded as last active channel.
|
||||
var InternalChannels = map[string]bool{
|
||||
"cli": true,
|
||||
"system": true,
|
||||
"subagent": true,
|
||||
var internalChannels = map[string]struct{}{
|
||||
"cli": {},
|
||||
"system": {},
|
||||
"subagent": {},
|
||||
}
|
||||
|
||||
// IsInternalChannel returns true if the channel is an internal channel.
|
||||
func IsInternalChannel(channel string) bool {
|
||||
return InternalChannels[channel]
|
||||
_, found := internalChannels[channel]
|
||||
return found
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error {
|
|||
|
||||
func (cs *CronService) saveStoreUnsafe() error {
|
||||
dir := filepath.Dir(cs.storePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
|
|||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cs.storePath, data, 0600)
|
||||
return os.WriteFile(cs.storePath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
|
||||
func (cs *CronService) AddJob(
|
||||
name string,
|
||||
schedule CronSchedule,
|
||||
message string,
|
||||
deliver bool,
|
||||
channel, to string,
|
||||
) (*CronJob, error) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
|
|
@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
|||
return enabled
|
||||
}
|
||||
|
||||
func (cs *CronService) Status() map[string]interface{} {
|
||||
func (cs *CronService) Status() map[string]any {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
|
|
@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
return map[string]any{
|
||||
"enabled": cs.running,
|
||||
"jobs": len(cs.store.Jobs),
|
||||
"nextWakeAtMS": cs.getNextWakeMS(),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
|||
}
|
||||
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
if perm != 0o600 {
|
||||
t.Errorf("cron store has permission %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error {
|
|||
for _, src := range s.sources {
|
||||
eventCh, err := src.Start(s.ctx)
|
||||
if err != nil {
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]any{
|
||||
"kind": src.Kind(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
go s.handleEvents(src.Kind(), eventCh)
|
||||
logger.InfoCF("devices", "Device source started", map[string]interface{}{
|
||||
logger.InfoCF("devices", "Device source started", map[string]any{
|
||||
"kind": src.Kind(),
|
||||
})
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
|||
|
||||
lastChannel := s.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{
|
||||
"event": ev.FormatMessage(),
|
||||
})
|
||||
return
|
||||
|
|
@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
|||
Content: msg,
|
||||
})
|
||||
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]any{
|
||||
"kind": ev.Kind,
|
||||
"action": ev.Action,
|
||||
"to": platform,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
|
|||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
|
|||
if result.Async {
|
||||
hs.logInfo("Async task started: %s", result.ForLLM)
|
||||
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"message": result.ForLLM,
|
||||
})
|
||||
return
|
||||
|
|
@ -275,7 +275,7 @@ This file contains tasks for the heartbeat service to check periodically.
|
|||
Add your heartbeat tasks below this line:
|
||||
`
|
||||
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
||||
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||
} else {
|
||||
hs.logInfo("Created default HEARTBEAT.md template")
|
||||
|
|
@ -354,7 +354,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
|
|||
// log writes a message to the heartbeat log file
|
||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
// Execute heartbeat directly (internal method for testing)
|
||||
hs.executeHeartbeat()
|
||||
|
|
@ -68,7 +68,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
|
||||
|
||||
// Should not panic with nil result
|
||||
hs.executeHeartbeat()
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ type Logger struct {
|
|||
}
|
||||
|
||||
type LogEntry struct {
|
||||
Level string `json:"level"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
Level string `json:"level"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
@ -71,7 +71,7 @@ func EnableFileLogging(filePath string) error {
|
|||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ func DisableFileLogging() {
|
|||
}
|
||||
}
|
||||
|
||||
func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) {
|
||||
func logMessage(level LogLevel, component string, message string, fields map[string]any) {
|
||||
if level < currentLevel {
|
||||
return
|
||||
}
|
||||
|
|
@ -150,7 +150,7 @@ func formatComponent(component string) string {
|
|||
return fmt.Sprintf(" %s:", component)
|
||||
}
|
||||
|
||||
func formatFields(fields map[string]interface{}) string {
|
||||
func formatFields(fields map[string]any) string {
|
||||
var parts []string
|
||||
for k, v := range fields {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
|
||||
|
|
@ -166,11 +166,11 @@ func DebugC(component string, message string) {
|
|||
logMessage(DEBUG, component, message, nil)
|
||||
}
|
||||
|
||||
func DebugF(message string, fields map[string]interface{}) {
|
||||
func DebugF(message string, fields map[string]any) {
|
||||
logMessage(DEBUG, "", message, fields)
|
||||
}
|
||||
|
||||
func DebugCF(component string, message string, fields map[string]interface{}) {
|
||||
func DebugCF(component string, message string, fields map[string]any) {
|
||||
logMessage(DEBUG, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -182,11 +182,11 @@ func InfoC(component string, message string) {
|
|||
logMessage(INFO, component, message, nil)
|
||||
}
|
||||
|
||||
func InfoF(message string, fields map[string]interface{}) {
|
||||
func InfoF(message string, fields map[string]any) {
|
||||
logMessage(INFO, "", message, fields)
|
||||
}
|
||||
|
||||
func InfoCF(component string, message string, fields map[string]interface{}) {
|
||||
func InfoCF(component string, message string, fields map[string]any) {
|
||||
logMessage(INFO, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -198,11 +198,11 @@ func WarnC(component string, message string) {
|
|||
logMessage(WARN, component, message, nil)
|
||||
}
|
||||
|
||||
func WarnF(message string, fields map[string]interface{}) {
|
||||
func WarnF(message string, fields map[string]any) {
|
||||
logMessage(WARN, "", message, fields)
|
||||
}
|
||||
|
||||
func WarnCF(component string, message string, fields map[string]interface{}) {
|
||||
func WarnCF(component string, message string, fields map[string]any) {
|
||||
logMessage(WARN, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -214,11 +214,11 @@ func ErrorC(component string, message string) {
|
|||
logMessage(ERROR, component, message, nil)
|
||||
}
|
||||
|
||||
func ErrorF(message string, fields map[string]interface{}) {
|
||||
func ErrorF(message string, fields map[string]any) {
|
||||
logMessage(ERROR, "", message, fields)
|
||||
}
|
||||
|
||||
func ErrorCF(component string, message string, fields map[string]interface{}) {
|
||||
func ErrorCF(component string, message string, fields map[string]any) {
|
||||
logMessage(ERROR, component, message, fields)
|
||||
}
|
||||
|
||||
|
|
@ -230,10 +230,10 @@ func FatalC(component string, message string) {
|
|||
logMessage(FATAL, component, message, nil)
|
||||
}
|
||||
|
||||
func FatalF(message string, fields map[string]interface{}) {
|
||||
func FatalF(message string, fields map[string]any) {
|
||||
logMessage(FATAL, "", message, fields)
|
||||
}
|
||||
|
||||
func FatalCF(component string, message string, fields map[string]interface{}) {
|
||||
func FatalCF(component string, message string, fields map[string]any) {
|
||||
logMessage(FATAL, component, message, fields)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,11 +54,11 @@ func TestLoggerWithComponent(t *testing.T) {
|
|||
name string
|
||||
component string
|
||||
message string
|
||||
fields map[string]interface{}
|
||||
fields map[string]any
|
||||
}{
|
||||
{"Simple message", "test", "Hello, world!", nil},
|
||||
{"Message with component", "discord", "Discord message", nil},
|
||||
{"Message with fields", "telegram", "Telegram message", map[string]interface{}{
|
||||
{"Message with fields", "telegram", "Telegram message", map[string]any{
|
||||
"user_id": "12345",
|
||||
"count": 42,
|
||||
}},
|
||||
|
|
@ -128,12 +128,12 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
|||
Error("This should log")
|
||||
|
||||
InfoC("test", "Component message")
|
||||
InfoF("Fields message", map[string]interface{}{"key": "value"})
|
||||
InfoF("Fields message", map[string]any{"key": "value"})
|
||||
|
||||
WarnC("test", "Warning with component")
|
||||
ErrorF("Error with fields", map[string]interface{}{"error": "test"})
|
||||
ErrorF("Error with fields", map[string]any{"error": "test"})
|
||||
|
||||
SetLevel(DEBUG)
|
||||
DebugC("test", "Debug with component")
|
||||
WarnF("Warning with fields", map[string]interface{}{"key": "value"})
|
||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ import (
|
|||
)
|
||||
|
||||
var supportedProviders = map[string]bool{
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"groq": true,
|
||||
"zhipu": true,
|
||||
"vllm": true,
|
||||
"gemini": true,
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"groq": true,
|
||||
"zhipu": true,
|
||||
"vllm": true,
|
||||
"gemini": true,
|
||||
"qwen": true,
|
||||
"deepseek": true,
|
||||
"github_copilot": true,
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
|
|
@ -44,26 +47,26 @@ func findOpenClawConfig(openclawHome string) (string, error) {
|
|||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
||||
}
|
||||
|
||||
func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
|
||||
func LoadOpenClawConfig(configPath string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
converted := convertKeysToSnake(raw)
|
||||
result, ok := converted.(map[string]interface{})
|
||||
result, ok := converted.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected config format")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
|
||||
func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
||||
cfg := config.DefaultConfig()
|
||||
var warnings []string
|
||||
|
||||
|
|
@ -76,7 +79,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
cfg.Agents.Defaults.MaxTokens = int(v)
|
||||
}
|
||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
||||
cfg.Agents.Defaults.Temperature = v
|
||||
cfg.Agents.Defaults.Temperature = &v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
||||
|
|
@ -89,7 +92,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
|
||||
if providers, ok := getMap(data, "providers"); ok {
|
||||
for name, val := range providers {
|
||||
pMap, ok := val.(map[string]interface{})
|
||||
pMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -108,7 +111,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
case "anthropic":
|
||||
cfg.Providers.Anthropic = pc
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI = pc
|
||||
cfg.Providers.OpenAI = config.OpenAIProviderConfig{
|
||||
ProviderConfig: pc,
|
||||
WebSearch: getBoolOrDefault(pMap, "web_search", true),
|
||||
}
|
||||
case "openrouter":
|
||||
cfg.Providers.OpenRouter = pc
|
||||
case "groq":
|
||||
|
|
@ -125,7 +131,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
|
||||
if channels, ok := getMap(data, "channels"); ok {
|
||||
for name, val := range channels {
|
||||
cMap, ok := val.(map[string]interface{})
|
||||
cMap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -253,6 +259,15 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
|
|||
if existing.Providers.Gemini.APIKey == "" {
|
||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
||||
}
|
||||
if existing.Providers.DeepSeek.APIKey == "" {
|
||||
existing.Providers.DeepSeek = incoming.Providers.DeepSeek
|
||||
}
|
||||
if existing.Providers.GitHubCopilot.APIBase == "" {
|
||||
existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
|
||||
}
|
||||
if existing.Providers.Qwen.APIKey == "" {
|
||||
existing.Providers.Qwen = incoming.Providers.Qwen
|
||||
}
|
||||
|
||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
||||
|
|
@ -303,16 +318,16 @@ func camelToSnake(s string) string {
|
|||
return result.String()
|
||||
}
|
||||
|
||||
func convertKeysToSnake(data interface{}) interface{} {
|
||||
func convertKeysToSnake(data any) any {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{}, len(v))
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = convertKeysToSnake(val)
|
||||
}
|
||||
|
|
@ -327,16 +342,16 @@ func rewriteWorkspacePath(path string) string {
|
|||
return path
|
||||
}
|
||||
|
||||
func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
|
||||
func getMap(data map[string]any, key string) (map[string]any, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func getString(data map[string]interface{}, key string) (string, bool) {
|
||||
func getString(data map[string]any, key string) (string, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
|
|
@ -345,7 +360,7 @@ func getString(data map[string]interface{}, key string) (string, bool) {
|
|||
return s, ok
|
||||
}
|
||||
|
||||
func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
||||
func getFloat(data map[string]any, key string) (float64, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
|
|
@ -354,7 +369,7 @@ func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
|||
return f, ok
|
||||
}
|
||||
|
||||
func getBool(data map[string]interface{}, key string) (bool, bool) {
|
||||
func getBool(data map[string]any, key string) (bool, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
|
|
@ -363,12 +378,19 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
|
|||
return b, ok
|
||||
}
|
||||
|
||||
func getStringSlice(data map[string]interface{}, key string) []string {
|
||||
func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
|
||||
if v, ok := getBool(data, key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getStringSlice(data map[string]any, key string) []string {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
arr, ok := v.([]interface{})
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
||||
}
|
||||
case ActionCreateDir:
|
||||
if err := os.MkdirAll(action.Destination, 0755); err != nil {
|
||||
if err := os.MkdirAll(action.Destination, 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
} else {
|
||||
result.DirsCreated++
|
||||
|
|
@ -174,9 +174,13 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
continue
|
||||
}
|
||||
result.BackupsCreated++
|
||||
fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
|
||||
fmt.Printf(
|
||||
" ✓ Backed up %s -> %s.bak\n",
|
||||
filepath.Base(action.Destination),
|
||||
filepath.Base(action.Destination),
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
|
|
@ -188,7 +192,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
|||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
}
|
||||
case ActionCopy:
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
|
|
@ -226,7 +230,7 @@ func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) e
|
|||
incoming = MergeConfig(existing, incoming)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.SaveConfig(dstConfigPath, incoming)
|
||||
|
|
|
|||
|
|
@ -40,43 +40,43 @@ func TestCamelToSnake(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConvertKeysToSnake(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
input := map[string]any{
|
||||
"apiKey": "test-key",
|
||||
"apiBase": "https://example.com",
|
||||
"nested": map[string]interface{}{
|
||||
"nested": map[string]any{
|
||||
"maxTokens": float64(8192),
|
||||
"allowFrom": []interface{}{"user1", "user2"},
|
||||
"deeperLevel": map[string]interface{}{
|
||||
"allowFrom": []any{"user1", "user2"},
|
||||
"deeperLevel": map[string]any{
|
||||
"clientId": "abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := convertKeysToSnake(input)
|
||||
m, ok := result.(map[string]interface{})
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, ok := m["api_key"]; !ok {
|
||||
if _, ok = m["api_key"]; !ok {
|
||||
t.Error("expected key 'api_key' after conversion")
|
||||
}
|
||||
if _, ok := m["api_base"]; !ok {
|
||||
if _, ok = m["api_base"]; !ok {
|
||||
t.Error("expected key 'api_base' after conversion")
|
||||
}
|
||||
|
||||
nested, ok := m["nested"].(map[string]interface{})
|
||||
nested, ok := m["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected nested map")
|
||||
}
|
||||
if _, ok := nested["max_tokens"]; !ok {
|
||||
if _, ok = nested["max_tokens"]; !ok {
|
||||
t.Error("expected key 'max_tokens' in nested map")
|
||||
}
|
||||
if _, ok := nested["allow_from"]; !ok {
|
||||
if _, ok = nested["allow_from"]; !ok {
|
||||
t.Error("expected key 'allow_from' in nested map")
|
||||
}
|
||||
|
||||
deeper, ok := nested["deeper_level"].(map[string]interface{})
|
||||
deeper, ok := nested["deeper_level"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected deeper_level map")
|
||||
}
|
||||
|
|
@ -89,15 +89,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
openclawConfig := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
openclawConfig := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ant-test123",
|
||||
"apiBase": "https://api.anthropic.com",
|
||||
},
|
||||
},
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"agents": map[string]any{
|
||||
"defaults": map[string]any{
|
||||
"maxTokens": float64(4096),
|
||||
"model": "claude-3-opus",
|
||||
},
|
||||
|
|
@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -117,11 +117,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
t.Fatalf("LoadOpenClawConfig: %v", err)
|
||||
}
|
||||
|
||||
providers, ok := result["providers"].(map[string]interface{})
|
||||
providers, ok := result["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected providers map")
|
||||
}
|
||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
||||
anthropic, ok := providers["anthropic"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic map")
|
||||
}
|
||||
|
|
@ -129,11 +129,11 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
|
||||
}
|
||||
|
||||
agents, ok := result["agents"].(map[string]interface{})
|
||||
agents, ok := result["agents"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected agents map")
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]interface{})
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected defaults map")
|
||||
}
|
||||
|
|
@ -144,16 +144,16 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
|
||||
func TestConvertConfig(t *testing.T) {
|
||||
t.Run("providers mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"api_key": "sk-ant-test",
|
||||
"api_base": "https://api.anthropic.com",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"openrouter": map[string]any{
|
||||
"api_key": "sk-or-test",
|
||||
},
|
||||
"groq": map[string]interface{}{
|
||||
"groq": map[string]any{
|
||||
"api_key": "gsk-test",
|
||||
},
|
||||
},
|
||||
|
|
@ -178,10 +178,10 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"deepseek": map[string]interface{}{
|
||||
"api_key": "sk-deep-test",
|
||||
data := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"unknown_provider": map[string]any{
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -193,20 +193,20 @@ func TestConvertConfig(t *testing.T) {
|
|||
if len(warnings) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||
}
|
||||
if warnings[0] != "Provider 'deepseek' not supported in PicoClaw, skipping" {
|
||||
if warnings[0] != "Provider 'unknown_provider' not supported in PicoClaw, skipping" {
|
||||
t.Errorf("unexpected warning: %s", warnings[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channels mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"channels": map[string]any{
|
||||
"telegram": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "tg-token-123",
|
||||
"allow_from": []interface{}{"user1"},
|
||||
"allow_from": []any{"user1"},
|
||||
},
|
||||
"discord": map[string]interface{}{
|
||||
"discord": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "disc-token-456",
|
||||
},
|
||||
|
|
@ -232,9 +232,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("unsupported channel warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"email": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"channels": map[string]any{
|
||||
"email": map[string]any{
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
|
|
@ -253,9 +253,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("agent defaults", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"agents": map[string]any{
|
||||
"defaults": map[string]any{
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": float64(4096),
|
||||
"temperature": 0.5,
|
||||
|
|
@ -275,8 +275,11 @@ func TestConvertConfig(t *testing.T) {
|
|||
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
||||
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
||||
}
|
||||
if cfg.Agents.Defaults.Temperature != 0.5 {
|
||||
t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5)
|
||||
if cfg.Agents.Defaults.Temperature == nil {
|
||||
t.Fatalf("Temperature is nil, want %f", 0.5)
|
||||
}
|
||||
if *cfg.Agents.Defaults.Temperature != 0.5 {
|
||||
t.Errorf("Temperature = %f, want %f", *cfg.Agents.Defaults.Temperature, 0.5)
|
||||
}
|
||||
if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
|
||||
|
|
@ -284,7 +287,7 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("empty config", func(t *testing.T) {
|
||||
data := map[string]interface{}{}
|
||||
data := map[string]any{}
|
||||
|
||||
cfg, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
|
|
@ -299,6 +302,24 @@ func TestConvertConfig(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestSupportedProvidersCompatibility(t *testing.T) {
|
||||
expected := []string{
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"groq",
|
||||
"zhipu",
|
||||
"vllm",
|
||||
"gemini",
|
||||
}
|
||||
|
||||
for _, provider := range expected {
|
||||
if !supportedProviders[provider] {
|
||||
t.Fatalf("supportedProviders missing expected key %q", provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig(t *testing.T) {
|
||||
t.Run("fills empty fields", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
|
|
@ -368,9 +389,9 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -399,8 +420,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -422,8 +443,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
||||
if err != nil {
|
||||
|
|
@ -442,8 +463,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
dstDir := t.TempDir()
|
||||
|
||||
memDir := filepath.Join(srcDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
|
||||
os.MkdirAll(memDir, 0o755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -473,8 +494,8 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
dstDir := t.TempDir()
|
||||
|
||||
skillDir := filepath.Join(srcDir, "skills", "weather")
|
||||
os.MkdirAll(skillDir, 0755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
|
||||
os.MkdirAll(skillDir, 0o755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
|
|
@ -497,7 +518,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("finds openclaw.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -511,7 +532,7 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("falls back to config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
os.WriteFile(configPath, []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -525,8 +546,8 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(openclawPath, []byte("{}"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
|
||||
os.WriteFile(openclawPath, []byte("{}"), 0o644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
|
|
@ -572,19 +593,19 @@ func TestRunDryRun(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "test-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
DryRun: true,
|
||||
|
|
@ -613,33 +634,33 @@ func TestRunFullMigration(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
|
||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
|
||||
|
||||
memDir := filepath.Join(wsDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
|
||||
os.MkdirAll(memDir, 0o755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ant-migrate-test",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"openrouter": map[string]any{
|
||||
"apiKey": "sk-or-migrate-test",
|
||||
},
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
"channels": map[string]any{
|
||||
"telegram": map[string]any{
|
||||
"enabled": true,
|
||||
"token": "tg-migrate-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
@ -733,7 +754,7 @@ func TestRunMutuallyExclusiveFlags(t *testing.T) {
|
|||
func TestBackupFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test.md")
|
||||
os.WriteFile(filePath, []byte("original content"), 0644)
|
||||
os.WriteFile(filePath, []byte("original content"), 0o644)
|
||||
|
||||
if err := backupFile(filePath); err != nil {
|
||||
t.Fatalf("backupFile: %v", err)
|
||||
|
|
@ -754,7 +775,7 @@ func TestCopyFile(t *testing.T) {
|
|||
srcPath := filepath.Join(tmpDir, "src.md")
|
||||
dstPath := filepath.Join(tmpDir, "dst.md")
|
||||
|
||||
os.WriteFile(srcPath, []byte("file content"), 0644)
|
||||
os.WriteFile(srcPath, []byte("file content"), 0o644)
|
||||
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
|
|
@ -774,18 +795,18 @@ func TestRunConfigOnly(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-config-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
@ -814,18 +835,18 @@ func TestRunWorkspaceOnly(t *testing.T) {
|
|||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.MkdirAll(wsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
configData := map[string]any{
|
||||
"providers": map[string]any{
|
||||
"anthropic": map[string]any{
|
||||
"apiKey": "sk-ws-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
|
|
|
|||
262
pkg/providers/anthropic/provider.go
Normal file
262
pkg/providers/anthropic/provider.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package anthropicprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
type (
|
||||
ToolCall = protocoltypes.ToolCall
|
||||
FunctionCall = protocoltypes.FunctionCall
|
||||
LLMResponse = protocoltypes.LLMResponse
|
||||
UsageInfo = protocoltypes.UsageInfo
|
||||
Message = protocoltypes.Message
|
||||
ToolDefinition = protocoltypes.ToolDefinition
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.anthropic.com"
|
||||
|
||||
type Provider struct {
|
||||
client *anthropic.Client
|
||||
tokenSource func() (string, error)
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewProvider(token string) *Provider {
|
||||
return NewProviderWithBaseURL(token, "")
|
||||
}
|
||||
|
||||
func NewProviderWithBaseURL(token, apiBase string) *Provider {
|
||||
baseURL := normalizeBaseURL(apiBase)
|
||||
client := anthropic.NewClient(
|
||||
option.WithAuthToken(token),
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
return &Provider{
|
||||
client: &client,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func NewProviderWithClient(client *anthropic.Client) *Provider {
|
||||
return &Provider{
|
||||
client: client,
|
||||
baseURL: defaultBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {
|
||||
return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "")
|
||||
}
|
||||
|
||||
func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {
|
||||
p := NewProviderWithBaseURL(token, apiBase)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
if p.tokenSource != nil {
|
||||
tok, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
opts = append(opts, option.WithAuthToken(tok))
|
||||
}
|
||||
|
||||
params, err := buildParams(messages, tools, model, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
return parseResponse(resp), nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetDefaultModel() string {
|
||||
return "claude-sonnet-4.6"
|
||||
}
|
||||
|
||||
func (p *Provider) BaseURL() string {
|
||||
return p.baseURL
|
||||
}
|
||||
|
||||
func buildParams(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (anthropic.MessageNewParams, error) {
|
||||
var system []anthropic.TextBlockParam
|
||||
var anthropicMessages []anthropic.MessageParam
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
var blocks []anthropic.ContentBlockParamUnion
|
||||
if msg.Content != "" {
|
||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "tool":
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxTokens := int64(4096)
|
||||
if mt, ok := options["max_tokens"].(int); ok {
|
||||
maxTokens = int64(mt)
|
||||
}
|
||||
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(model),
|
||||
Messages: anthropicMessages,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
|
||||
if len(system) > 0 {
|
||||
params.System = system
|
||||
}
|
||||
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
params.Temperature = anthropic.Float(temp)
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
params.Tools = translateTools(tools)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
tool := anthropic.ToolParam{
|
||||
Name: t.Function.Name,
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: t.Function.Parameters["properties"],
|
||||
},
|
||||
}
|
||||
if desc := t.Function.Description; desc != "" {
|
||||
tool.Description = anthropic.String(desc)
|
||||
}
|
||||
if req, ok := t.Function.Parameters["required"].([]any); ok {
|
||||
required := make([]string, 0, len(req))
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
tool.InputSchema.Required = required
|
||||
}
|
||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||
var content string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
tb := block.AsText()
|
||||
content += tb.Text
|
||||
case "tool_use":
|
||||
tu := block.AsToolUse()
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
|
||||
args = map[string]any{"raw": string(tu.Input)}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: tu.ID,
|
||||
Name: tu.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
switch resp.StopReason {
|
||||
case anthropic.StopReasonToolUse:
|
||||
finishReason = "tool_calls"
|
||||
case anthropic.StopReasonMaxTokens:
|
||||
finishReason = "length"
|
||||
case anthropic.StopReasonEndTurn:
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: int(resp.Usage.InputTokens),
|
||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBaseURL(apiBase string) string {
|
||||
base := strings.TrimSpace(apiBase)
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
}
|
||||
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
}
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
271
pkg/providers/anthropic/provider_test.go
Normal file
271
pkg/providers/anthropic/provider_test.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
package anthropicprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
)
|
||||
|
||||
func TestBuildParams_BasicMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if string(params.Model) != "claude-sonnet-4.6" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6")
|
||||
}
|
||||
if params.MaxTokens != 1024 {
|
||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParams_SystemMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if len(params.System) != 1 {
|
||||
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
||||
}
|
||||
if params.System[0].Text != "You are helpful" {
|
||||
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParams_ToolCallMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "SF"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if len(params.Messages) != 3 {
|
||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParams_WithTools(t *testing.T) {
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a city",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() error: %v", err)
|
||||
}
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_TextOnly(t *testing.T) {
|
||||
resp := &anthropic.Message{
|
||||
Content: []anthropic.ContentBlockUnion{},
|
||||
Usage: anthropic.Usage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
},
|
||||
}
|
||||
result := parseResponse(resp)
|
||||
if result.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
||||
}
|
||||
if result.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
||||
}
|
||||
if result.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_StopReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
stopReason anthropic.StopReason
|
||||
want string
|
||||
}{
|
||||
{anthropic.StopReasonEndTurn, "stop"},
|
||||
{anthropic.StopReasonMaxTokens, "length"},
|
||||
{anthropic.StopReasonToolUse, "tool_calls"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp := &anthropic.Message{
|
||||
StopReason: tt.stopReason,
|
||||
}
|
||||
result := parseResponse(resp)
|
||||
if result.FinishReason != tt.want {
|
||||
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ChatRoundTrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "Hello! How can I help you?"},
|
||||
},
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 8,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello! How can I help you?" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 15 {
|
||||
t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewProvider("test-token")
|
||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) {
|
||||
p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/")
|
||||
if got := p.BaseURL(); got != "https://api.anthropic.com" {
|
||||
t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
||||
var requests int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&requests, 1)
|
||||
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "ok"},
|
||||
},
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 1,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
|
||||
return "refreshed-token", nil
|
||||
}, server.URL)
|
||||
|
||||
_, err := p.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"claude-sonnet-4.6",
|
||||
map[string]any{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&requests); got != 1 {
|
||||
t.Fatalf("requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
||||
c := anthropic.NewClient(
|
||||
anthropicoption.WithAuthToken(token),
|
||||
anthropicoption.WithBaseURL(baseURL),
|
||||
)
|
||||
return &c
|
||||
}
|
||||
855
pkg/providers/antigravity_provider.go
Normal file
855
pkg/providers/antigravity_provider.go
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityDefaultModel = "gemini-3-flash"
|
||||
antigravityUserAgent = "antigravity"
|
||||
antigravityXGoogClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||
antigravityVersion = "1.15.8"
|
||||
)
|
||||
|
||||
// AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API.
|
||||
// This provider authenticates via Google OAuth and provides access to models like Claude and Gemini
|
||||
// through Google's infrastructure.
|
||||
type AntigravityProvider struct {
|
||||
tokenSource func() (string, string, error) // Returns (accessToken, projectID, error)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAntigravityProvider creates a new Antigravity provider using stored auth credentials.
|
||||
func NewAntigravityProvider() *AntigravityProvider {
|
||||
return &AntigravityProvider{
|
||||
tokenSource: createAntigravityTokenSource(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
|
||||
// The v1internal endpoint wraps the standard Gemini request in an envelope with
|
||||
// project, model, request, requestType, userAgent, and requestId fields.
|
||||
func (p *AntigravityProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
accessToken, projectID, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity auth: %w", err)
|
||||
}
|
||||
|
||||
if model == "" || model == "antigravity" || model == "google-antigravity" {
|
||||
model = antigravityDefaultModel
|
||||
}
|
||||
// Strip provider prefixes if present
|
||||
model = strings.TrimPrefix(model, "google-antigravity/")
|
||||
model = strings.TrimPrefix(model, "antigravity/")
|
||||
|
||||
logger.DebugCF("provider.antigravity", "Starting chat", map[string]any{
|
||||
"model": model,
|
||||
"project": projectID,
|
||||
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
||||
})
|
||||
|
||||
// Build the inner Gemini-format request
|
||||
innerRequest := p.buildRequest(messages, tools, model, options)
|
||||
|
||||
// Wrap in v1internal envelope (matches pi-ai SDK format)
|
||||
envelope := map[string]any{
|
||||
"project": projectID,
|
||||
"model": model,
|
||||
"request": innerRequest,
|
||||
"requestType": "agent",
|
||||
"userAgent": antigravityUserAgent,
|
||||
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling request: %w", err)
|
||||
}
|
||||
|
||||
// Build API URL — uses Cloud Code Assist v1internal streaming endpoint
|
||||
apiURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", antigravityBaseURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
// Headers matching the pi-ai SDK antigravity format
|
||||
clientMetadata, _ := json.Marshal(map[string]string{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
})
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("antigravity/%s linux/amd64", antigravityVersion))
|
||||
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||
req.Header.Set("Client-Metadata", string(clientMetadata))
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity API call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.ErrorCF("provider.antigravity", "API call failed", map[string]any{
|
||||
"status_code": resp.StatusCode,
|
||||
"response": string(respBody),
|
||||
"model": model,
|
||||
})
|
||||
|
||||
return nil, p.parseAntigravityError(resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
// Response is always SSE from streamGenerateContent — each line is "data: {...}"
|
||||
// with a "response" wrapper containing the standard Gemini response
|
||||
llmResp, err := p.parseSSEResponse(string(respBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for empty response (some models might return valid success but empty text)
|
||||
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"antigravity: model returned an empty response (this model might be invalid or restricted)",
|
||||
)
|
||||
}
|
||||
|
||||
return llmResp, nil
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model identifier.
|
||||
func (p *AntigravityProvider) GetDefaultModel() string {
|
||||
return antigravityDefaultModel
|
||||
}
|
||||
|
||||
// --- Request building ---
|
||||
|
||||
type antigravityRequest struct {
|
||||
Contents []antigravityContent `json:"contents"`
|
||||
Tools []antigravityTool `json:"tools,omitempty"`
|
||||
SystemPrompt *antigravitySystemPrompt `json:"systemInstruction,omitempty"`
|
||||
Config *antigravityGenConfig `json:"generationConfig,omitempty"`
|
||||
}
|
||||
|
||||
type antigravityContent struct {
|
||||
Role string `json:"role"`
|
||||
Parts []antigravityPart `json:"parts"`
|
||||
}
|
||||
|
||||
type antigravityPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *antigravityFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type antigravityFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
type antigravityFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]any `json:"response"`
|
||||
}
|
||||
|
||||
type antigravityTool struct {
|
||||
FunctionDeclarations []antigravityFuncDecl `json:"functionDeclarations"`
|
||||
}
|
||||
|
||||
type antigravityFuncDecl struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type antigravitySystemPrompt struct {
|
||||
Parts []antigravityPart `json:"parts"`
|
||||
}
|
||||
|
||||
type antigravityGenConfig struct {
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) buildRequest(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) antigravityRequest {
|
||||
req := antigravityRequest{}
|
||||
toolCallNames := make(map[string]string)
|
||||
|
||||
// Build contents from messages
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
req.SystemPrompt = &antigravitySystemPrompt{
|
||||
Parts: []antigravityPart{{Text: msg.Content}},
|
||||
}
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||
// Tool result
|
||||
req.Contents = append(req.Contents, antigravityContent{
|
||||
Role: "user",
|
||||
Parts: []antigravityPart{{
|
||||
FunctionResponse: &antigravityFunctionResponse{
|
||||
Name: toolName,
|
||||
Response: map[string]any{
|
||||
"result": msg.Content,
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
} else {
|
||||
req.Contents = append(req.Contents, antigravityContent{
|
||||
Role: "user",
|
||||
Parts: []antigravityPart{{Text: msg.Content}},
|
||||
})
|
||||
}
|
||||
case "assistant":
|
||||
content := antigravityContent{
|
||||
Role: "model",
|
||||
}
|
||||
if msg.Content != "" {
|
||||
content.Parts = append(content.Parts, antigravityPart{Text: msg.Content})
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
||||
if toolName == "" {
|
||||
logger.WarnCF(
|
||||
"provider.antigravity",
|
||||
"Skipping tool call with empty name in history",
|
||||
map[string]any{
|
||||
"tool_call_id": tc.ID,
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
if tc.ID != "" {
|
||||
toolCallNames[tc.ID] = toolName
|
||||
}
|
||||
content.Parts = append(content.Parts, antigravityPart{
|
||||
ThoughtSignature: thoughtSignature,
|
||||
ThoughtSignatureSnake: thoughtSignature,
|
||||
FunctionCall: &antigravityFunctionCall{
|
||||
Name: toolName,
|
||||
Args: toolArgs,
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(content.Parts) > 0 {
|
||||
req.Contents = append(req.Contents, content)
|
||||
}
|
||||
case "tool":
|
||||
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||
req.Contents = append(req.Contents, antigravityContent{
|
||||
Role: "user",
|
||||
Parts: []antigravityPart{{
|
||||
FunctionResponse: &antigravityFunctionResponse{
|
||||
Name: toolName,
|
||||
Response: map[string]any{
|
||||
"result": msg.Content,
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build tools (sanitize schemas for Gemini compatibility)
|
||||
if len(tools) > 0 {
|
||||
var funcDecls []antigravityFuncDecl
|
||||
for _, t := range tools {
|
||||
if t.Type != "function" {
|
||||
continue
|
||||
}
|
||||
params := sanitizeSchemaForGemini(t.Function.Parameters)
|
||||
funcDecls = append(funcDecls, antigravityFuncDecl{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
Parameters: params,
|
||||
})
|
||||
}
|
||||
if len(funcDecls) > 0 {
|
||||
req.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}}
|
||||
}
|
||||
}
|
||||
|
||||
// Generation config
|
||||
config := &antigravityGenConfig{}
|
||||
if val, ok := options["max_tokens"]; ok {
|
||||
if maxTokens, ok := val.(int); ok && maxTokens > 0 {
|
||||
config.MaxOutputTokens = maxTokens
|
||||
} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
|
||||
config.MaxOutputTokens = int(maxTokens)
|
||||
}
|
||||
}
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
config.Temperature = temp
|
||||
}
|
||||
if config.MaxOutputTokens > 0 || config.Temperature > 0 {
|
||||
req.Config = config
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
|
||||
name := tc.Name
|
||||
args := tc.Arguments
|
||||
thoughtSignature := ""
|
||||
|
||||
if name == "" && tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
thoughtSignature = tc.Function.ThoughtSignature
|
||||
} else if tc.Function != nil {
|
||||
thoughtSignature = tc.Function.ThoughtSignature
|
||||
}
|
||||
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||
args = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return name, args, thoughtSignature
|
||||
}
|
||||
|
||||
func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string {
|
||||
if toolCallID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if name, ok := toolCallNames[toolCallID]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return inferToolNameFromCallID(toolCallID)
|
||||
}
|
||||
|
||||
func inferToolNameFromCallID(toolCallID string) string {
|
||||
if !strings.HasPrefix(toolCallID, "call_") {
|
||||
return toolCallID
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(toolCallID, "call_")
|
||||
if idx := strings.LastIndex(rest, "_"); idx > 0 {
|
||||
candidate := rest[:idx]
|
||||
if candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return toolCallID
|
||||
}
|
||||
|
||||
// --- Response parsing ---
|
||||
|
||||
type antigravityJSONResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||
} `json:"parts"`
|
||||
Role string `json:"role"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
|
||||
var resp antigravityJSONResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parsing antigravity response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Candidates) == 0 {
|
||||
return nil, fmt.Errorf("antigravity: no candidates in response")
|
||||
}
|
||||
|
||||
candidate := resp.Candidates[0]
|
||||
var contentParts []string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: part.FunctionCall.Args,
|
||||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
if candidate.FinishReason == "MAX_TOKENS" {
|
||||
finishReason = "length"
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.Join(contentParts, ""),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
||||
var contentParts []string
|
||||
var toolCalls []ToolCall
|
||||
var usage *UsageInfo
|
||||
var finishReason string
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(body))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
// v1internal SSE wraps the Gemini response in a "response" field
|
||||
var sseChunk struct {
|
||||
Response antigravityJSONResponse `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &sseChunk); err != nil {
|
||||
continue
|
||||
}
|
||||
resp := sseChunk.Response
|
||||
|
||||
for _, candidate := range resp.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: part.FunctionCall.Args,
|
||||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
ThoughtSignature: extractPartThoughtSignature(
|
||||
part.ThoughtSignature,
|
||||
part.ThoughtSignatureSnake,
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if candidate.FinishReason != "" {
|
||||
finishReason = candidate.FinishReason
|
||||
}
|
||||
}
|
||||
|
||||
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mappedFinish := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
mappedFinish = "tool_calls"
|
||||
}
|
||||
if finishReason == "MAX_TOKENS" {
|
||||
mappedFinish = "length"
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.Join(contentParts, ""),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: mappedFinish,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string {
|
||||
if thoughtSignature != "" {
|
||||
return thoughtSignature
|
||||
}
|
||||
if thoughtSignatureSnake != "" {
|
||||
return thoughtSignatureSnake
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Schema sanitization ---
|
||||
|
||||
// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.
|
||||
var geminiUnsupportedKeywords = map[string]bool{
|
||||
"patternProperties": true,
|
||||
"additionalProperties": true,
|
||||
"$schema": true,
|
||||
"$id": true,
|
||||
"$ref": true,
|
||||
"$defs": true,
|
||||
"definitions": true,
|
||||
"examples": true,
|
||||
"minLength": true,
|
||||
"maxLength": true,
|
||||
"minimum": true,
|
||||
"maximum": true,
|
||||
"multipleOf": true,
|
||||
"pattern": true,
|
||||
"format": true,
|
||||
"minItems": true,
|
||||
"maxItems": true,
|
||||
"uniqueItems": true,
|
||||
"minProperties": true,
|
||||
"maxProperties": true,
|
||||
}
|
||||
|
||||
func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
for k, v := range schema {
|
||||
if geminiUnsupportedKeywords[k] {
|
||||
continue
|
||||
}
|
||||
// Recursively sanitize nested objects
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
result[k] = sanitizeSchemaForGemini(val)
|
||||
case []any:
|
||||
sanitized := make([]any, len(val))
|
||||
for i, item := range val {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
sanitized[i] = sanitizeSchemaForGemini(m)
|
||||
} else {
|
||||
sanitized[i] = item
|
||||
}
|
||||
}
|
||||
result[k] = sanitized
|
||||
default:
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure top-level has type: "object" if properties are present
|
||||
if _, hasProps := result["properties"]; hasProps {
|
||||
if _, hasType := result["type"]; !hasType {
|
||||
result["type"] = "object"
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Token source ---
|
||||
|
||||
func createAntigravityTokenSource() func() (string, string, error) {
|
||||
return func() (string, string, error) {
|
||||
cred, err := auth.GetCredential("google-antigravity")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return "", "", fmt.Errorf(
|
||||
"no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh if needed
|
||||
if cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
refreshed.Email = cred.Email
|
||||
if refreshed.ProjectID == "" {
|
||||
refreshed.ProjectID = cred.ProjectID
|
||||
}
|
||||
if err := auth.SetCredential("google-antigravity", refreshed); err != nil {
|
||||
return "", "", fmt.Errorf("saving refreshed token: %w", err)
|
||||
}
|
||||
cred = refreshed
|
||||
}
|
||||
|
||||
if cred.IsExpired() {
|
||||
return "", "", fmt.Errorf(
|
||||
"antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
projectID := cred.ProjectID
|
||||
if projectID == "" {
|
||||
// Try to fetch project ID from API
|
||||
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
|
||||
if err != nil {
|
||||
logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
|
||||
} else {
|
||||
projectID = fetchedID
|
||||
cred.ProjectID = projectID
|
||||
_ = auth.SetCredential("google-antigravity", cred)
|
||||
}
|
||||
}
|
||||
|
||||
return cred.AccessToken, projectID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
|
||||
func FetchAntigravityProjectID(accessToken string) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:loadCodeAssist", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CloudAICompanionProject string `json:"cloudaicompanionProject"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if result.CloudAICompanionProject == "" {
|
||||
return "", fmt.Errorf("no project ID in loadCodeAssist response")
|
||||
}
|
||||
|
||||
return result.CloudAICompanionProject, nil
|
||||
}
|
||||
|
||||
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
|
||||
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"project": projectID,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:fetchAvailableModels", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf(
|
||||
"fetchAvailableModels failed (HTTP %d): %s",
|
||||
resp.StatusCode,
|
||||
truncateString(string(body), 200),
|
||||
)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models map[string]struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaInfo struct {
|
||||
RemainingFraction any `json:"remainingFraction"`
|
||||
ResetTime string `json:"resetTime"`
|
||||
IsExhausted bool `json:"isExhausted"`
|
||||
} `json:"quotaInfo"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parsing models response: %w", err)
|
||||
}
|
||||
|
||||
var models []AntigravityModelInfo
|
||||
for id, info := range result.Models {
|
||||
models = append(models, AntigravityModelInfo{
|
||||
ID: id,
|
||||
DisplayName: info.DisplayName,
|
||||
IsExhausted: info.QuotaInfo.IsExhausted,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already
|
||||
hasFlashPreview := false
|
||||
hasFlash := false
|
||||
for _, m := range models {
|
||||
if m.ID == "gemini-3-flash-preview" {
|
||||
hasFlashPreview = true
|
||||
}
|
||||
if m.ID == "gemini-3-flash" {
|
||||
hasFlash = true
|
||||
}
|
||||
}
|
||||
if !hasFlashPreview {
|
||||
models = append(models, AntigravityModelInfo{
|
||||
ID: "gemini-3-flash-preview",
|
||||
DisplayName: "Gemini 3 Flash (Preview)",
|
||||
})
|
||||
}
|
||||
if !hasFlash {
|
||||
models = append(models, AntigravityModelInfo{
|
||||
ID: "gemini-3-flash",
|
||||
DisplayName: "Gemini 3 Flash",
|
||||
})
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
type AntigravityModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsExhausted bool `json:"is_exhausted"`
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Details []map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return fmt.Errorf("antigravity API error (HTTP %d): %s", statusCode, truncateString(string(body), 500))
|
||||
}
|
||||
|
||||
msg := errResp.Error.Message
|
||||
if statusCode == 429 {
|
||||
// Try to extract quota reset info
|
||||
for _, detail := range errResp.Error.Details {
|
||||
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
|
||||
if metadata, ok := detail["metadata"].(map[string]any); ok {
|
||||
if delay, ok := metadata["quotaResetDelay"].(string); ok {
|
||||
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("antigravity rate limit exceeded: %s", msg)
|
||||
}
|
||||
|
||||
return fmt.Errorf("antigravity API error (%s): %s", errResp.Error.Status, msg)
|
||||
}
|
||||
56
pkg/providers/antigravity_provider_test.go
Normal file
56
pkg/providers/antigravity_provider_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package providers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
|
||||
p := &AntigravityProvider{}
|
||||
|
||||
messages := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_read_file_123",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
ToolCallID: "call_read_file_123",
|
||||
Content: "ok",
|
||||
},
|
||||
}
|
||||
|
||||
req := p.buildRequest(messages, nil, "", nil)
|
||||
if len(req.Contents) != 2 {
|
||||
t.Fatalf("expected 2 contents, got %d", len(req.Contents))
|
||||
}
|
||||
|
||||
modelPart := req.Contents[0].Parts[0]
|
||||
if modelPart.FunctionCall == nil {
|
||||
t.Fatal("expected functionCall in assistant message")
|
||||
}
|
||||
if modelPart.FunctionCall.Name != "read_file" {
|
||||
t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name)
|
||||
}
|
||||
if got := modelPart.FunctionCall.Args["path"]; got != "README.md" {
|
||||
t.Fatalf("expected functionCall args[path] to be README.md, got %v", got)
|
||||
}
|
||||
|
||||
toolPart := req.Contents[1].Parts[0]
|
||||
if toolPart.FunctionResponse == nil {
|
||||
t.Fatal("expected functionResponse in tool message")
|
||||
}
|
||||
if toolPart.FunctionResponse.Name != "read_file" {
|
||||
t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
|
||||
got := resolveToolResponseName("call_search_docs_999", map[string]string{})
|
||||
if got != "search_docs" {
|
||||
t.Fatalf("expected inferred tool name search_docs, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
|||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
||||
func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *ClaudeCliProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
systemPrompt := p.buildSystemPrompt(messages, tools)
|
||||
prompt := p.messagesToPrompt(messages)
|
||||
|
||||
|
|
@ -111,7 +113,9 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString(
|
||||
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
|
||||
)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ func TestIntegration_RealClaudeCLI(t *testing.T) {
|
|||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||
}
|
||||
|
|
@ -75,7 +74,6 @@ func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
|
|||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|||
dir := t.TempDir()
|
||||
|
||||
if stdout != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stderr != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||
|
||||
script := filepath.Join(dir, "claude")
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -67,7 +67,7 @@ func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
|||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -88,7 +88,7 @@ cat <<'EOFMOCK'
|
|||
{"type":"result","result":"ok","session_id":"test"}
|
||||
EOFMOCK
|
||||
`, argsFile)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
|
|
@ -137,7 +137,6 @@ func TestChat_Success(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -193,7 +192,6 @@ func TestChat_WithToolCallsInResponse(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -336,7 +334,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
|
|||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "claude-sonnet-4-5-20250929", nil)
|
||||
}, nil, "claude-sonnet-4.6", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
|
@ -346,7 +344,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
|
|||
if !strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args missing --model, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
||||
if !strings.Contains(args, "claude-sonnet-4.6") {
|
||||
t.Errorf("CLI args missing model name, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
|
@ -403,7 +401,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
|||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||
}
|
||||
|
|
@ -416,10 +413,12 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
|||
|
||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = "/test/ws"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
|
||||
}
|
||||
cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
provider, _, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
||||
}
|
||||
|
|
@ -435,9 +434,12 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
|
|||
|
||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-code"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
{ModelName: "claude-code", Model: "claude-cli/claude-code"},
|
||||
}
|
||||
cfg.Agents.Defaults.Model = "claude-code"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
provider, _, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
||||
}
|
||||
|
|
@ -448,9 +450,12 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
|
|||
|
||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claudecode"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
{ModelName: "claudecode", Model: "claude-cli/claudecode"},
|
||||
}
|
||||
cfg.Agents.Defaults.Model = "claudecode"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
provider, _, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
||||
}
|
||||
|
|
@ -461,10 +466,13 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
|||
|
||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.ModelList = []config.ModelConfig{
|
||||
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
|
||||
}
|
||||
cfg.Agents.Defaults.Model = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = ""
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
provider, _, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider error = %v", err)
|
||||
}
|
||||
|
|
@ -611,10 +619,10 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a location",
|
||||
Parameters: map[string]interface{}{
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string"},
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,200 +2,62 @@ package providers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||
)
|
||||
|
||||
type ClaudeProvider struct {
|
||||
client *anthropic.Client
|
||||
tokenSource func() (string, error)
|
||||
delegate *anthropicprovider.Provider
|
||||
}
|
||||
|
||||
func NewClaudeProvider(token string) *ClaudeProvider {
|
||||
client := anthropic.NewClient(
|
||||
option.WithAuthToken(token),
|
||||
option.WithBaseURL("https://api.anthropic.com"),
|
||||
)
|
||||
return &ClaudeProvider{client: &client}
|
||||
return &ClaudeProvider{
|
||||
delegate: anthropicprovider.NewProvider(token),
|
||||
}
|
||||
}
|
||||
|
||||
func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider {
|
||||
return &ClaudeProvider{
|
||||
delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase),
|
||||
}
|
||||
}
|
||||
|
||||
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
||||
p := NewClaudeProvider(token)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
return &ClaudeProvider{
|
||||
delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
if p.tokenSource != nil {
|
||||
tok, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
opts = append(opts, option.WithAuthToken(tok))
|
||||
func NewClaudeProviderWithTokenSourceAndBaseURL(
|
||||
token string, tokenSource func() (string, error), apiBase string,
|
||||
) *ClaudeProvider {
|
||||
return &ClaudeProvider{
|
||||
delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
|
||||
}
|
||||
}
|
||||
|
||||
params, err := buildClaudeParams(messages, tools, model, options)
|
||||
func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
|
||||
return &ClaudeProvider{delegate: delegate}
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
return parseClaudeResponse(resp), nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) GetDefaultModel() string {
|
||||
return "claude-sonnet-4-5-20250929"
|
||||
}
|
||||
|
||||
func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
||||
var system []anthropic.TextBlockParam
|
||||
var anthropicMessages []anthropic.MessageParam
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
var blocks []anthropic.ContentBlockParamUnion
|
||||
if msg.Content != "" {
|
||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "tool":
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxTokens := int64(4096)
|
||||
if mt, ok := options["max_tokens"].(int); ok {
|
||||
maxTokens = int64(mt)
|
||||
}
|
||||
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(model),
|
||||
Messages: anthropicMessages,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
|
||||
if len(system) > 0 {
|
||||
params.System = system
|
||||
}
|
||||
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
params.Temperature = anthropic.Float(temp)
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
params.Tools = translateToolsForClaude(tools)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
tool := anthropic.ToolParam{
|
||||
Name: t.Function.Name,
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: t.Function.Parameters["properties"],
|
||||
},
|
||||
}
|
||||
if desc := t.Function.Description; desc != "" {
|
||||
tool.Description = anthropic.String(desc)
|
||||
}
|
||||
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
||||
required := make([]string, 0, len(req))
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
tool.InputSchema.Required = required
|
||||
}
|
||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
|
||||
var content string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
tb := block.AsText()
|
||||
content += tb.Text
|
||||
case "tool_use":
|
||||
tu := block.AsToolUse()
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||
args = map[string]interface{}{"raw": string(tu.Input)}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: tu.ID,
|
||||
Name: tu.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
switch resp.StopReason {
|
||||
case anthropic.StopReasonToolUse:
|
||||
finishReason = "tool_calls"
|
||||
case anthropic.StopReasonMaxTokens:
|
||||
finishReason = "length"
|
||||
case anthropic.StopReasonEndTurn:
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: int(resp.Usage.InputTokens),
|
||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||
},
|
||||
}
|
||||
return p.delegate.GetDefaultModel()
|
||||
}
|
||||
|
||||
func createClaudeTokenSource() func() (string, error) {
|
||||
return func() (string, error) {
|
||||
cred, err := auth.GetCredential("anthropic")
|
||||
cred, err := getCredential("anthropic")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,140 +8,10 @@ import (
|
|||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
|
||||
)
|
||||
|
||||
func TestBuildClaudeParams_BasicMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
||||
}
|
||||
if params.MaxTokens != 1024 {
|
||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_SystemMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.System) != 1 {
|
||||
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
||||
}
|
||||
if params.System[0].Text != "You are helpful" {
|
||||
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]interface{}{"city": "SF"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.Messages) != 3 {
|
||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_WithTools(t *testing.T) {
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a city",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []interface{}{"city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeResponse_TextOnly(t *testing.T) {
|
||||
resp := &anthropic.Message{
|
||||
Content: []anthropic.ContentBlockUnion{},
|
||||
Usage: anthropic.Usage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
},
|
||||
}
|
||||
result := parseClaudeResponse(resp)
|
||||
if result.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
||||
}
|
||||
if result.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
||||
}
|
||||
if result.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeResponse_StopReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
stopReason anthropic.StopReason
|
||||
want string
|
||||
}{
|
||||
{anthropic.StopReasonEndTurn, "stop"},
|
||||
{anthropic.StopReasonMaxTokens, "length"},
|
||||
{anthropic.StopReasonToolUse, "tool_calls"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp := &anthropic.Message{
|
||||
StopReason: tt.stopReason,
|
||||
}
|
||||
result := parseClaudeResponse(resp)
|
||||
if result.FinishReason != tt.want {
|
||||
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
|
|
@ -153,19 +23,19 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
var reqBody map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]interface{}{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "Hello! How can I help you?"},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"usage": map[string]any{
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 8,
|
||||
},
|
||||
|
|
@ -175,11 +45,11 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewClaudeProvider("test-token")
|
||||
provider.client = createAnthropicTestClient(server.URL, "test-token")
|
||||
delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
||||
provider := newClaudeProviderWithDelegate(delegate)
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
|
@ -196,8 +66,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|||
|
||||
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewClaudeProvider("test-token")
|
||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
|
|||
}
|
||||
|
||||
var auth CodexCliAuth
|
||||
if err := json.Unmarshal(data, &auth); err != nil {
|
||||
if err = json.Unmarshal(data, &auth); err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +59,9 @@ func CreateCodexCliTokenSource() func() (string, string, error) {
|
|||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
|
||||
return "", "", fmt.Errorf(
|
||||
"codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login",
|
||||
)
|
||||
}
|
||||
|
||||
return token, accountID, nil
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
|||
"account_id": "org-test123"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
|||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -112,12 +112,12 @@ func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
|||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
||||
if err := os.MkdirAll(customDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(customDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
|||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider {
|
|||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
||||
func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
func (p *CodexCliProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.command == "" {
|
||||
return nil, fmt.Errorf("codex command not configured")
|
||||
}
|
||||
|
|
@ -133,7 +135,9 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString(
|
||||
`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`,
|
||||
)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
|
|
|||
117
pkg/providers/codex_cli_provider_integration_test.go
Normal file
117
pkg/providers/codex_cli_provider_integration_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//go:build integration
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
exec "os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI.
|
||||
// Run with: go test -tags=integration ./pkg/providers/...
|
||||
func TestIntegration_RealCodexCLI(t *testing.T) {
|
||||
path, err := exec.LookPath("codex")
|
||||
if err != nil {
|
||||
t.Skip("codex CLI not found in PATH, skipping integration test")
|
||||
}
|
||||
t.Logf("Using codex CLI at: %s", path)
|
||||
|
||||
p := NewCodexCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
t.Error("Content is empty")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
|
||||
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
t.Logf("Response content: %q", resp.Content)
|
||||
|
||||
if !strings.Contains(strings.ToLower(resp.Content), "pong") {
|
||||
t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
|
||||
if _, err := exec.LookPath("codex"); err != nil {
|
||||
t.Skip("codex CLI not found in PATH")
|
||||
}
|
||||
|
||||
p := NewCodexCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Response: %q", resp.Content)
|
||||
|
||||
if !strings.Contains(resp.Content, "4") {
|
||||
t.Errorf("Content = %q, expected to contain '4'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) {
|
||||
if _, err := exec.LookPath("codex"); err != nil {
|
||||
t.Skip("codex CLI not found in PATH")
|
||||
}
|
||||
|
||||
// Run codex directly and verify our parser handles real output
|
||||
cmd := exec.Command("codex", "exec",
|
||||
"--json",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
"--color", "never",
|
||||
"-C", t.TempDir(),
|
||||
"-")
|
||||
cmd.Stdin = strings.NewReader("Say hi")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// codex may write diagnostic noise to stderr but still produce valid output
|
||||
if len(output) == 0 {
|
||||
t.Fatalf("codex CLI failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Raw CLI output (first 500 chars): %s", string(output[:min(len(output), 500)]))
|
||||
|
||||
// Verify our parser can handle real output
|
||||
p := NewCodexCliProvider("")
|
||||
resp, err := p.parseJSONLEvents(string(output))
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() failed on real CLI output: %v", err)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
t.Error("parsed Content is empty")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
||||
}
|
||||
|
||||
t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue