Merge branch 'sipeed:main' into main
This commit is contained in:
commit
2df5f3d7f9
351 changed files with 25364 additions and 6951 deletions
9
.github/workflows/create_dmg.yml
vendored
9
.github/workflows/create_dmg.yml
vendored
|
|
@ -23,6 +23,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -30,9 +36,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
# 3. Build the application bundle
|
# 3. Build the application bundle
|
||||||
- name: Build with Make
|
- name: Build with Make
|
||||||
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||||
|
|
|
||||||
9
.github/workflows/nightly.yml
vendored
9
.github/workflows/nightly.yml
vendored
|
|
@ -47,6 +47,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -54,9 +60,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
|
|
||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
|
|
@ -65,6 +65,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -72,9 +78,6 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
run: corepack enable && corepack install
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page
|
||||||
Prérequis :
|
Prérequis :
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ avec Corepack activé pour les builds Web UI / launcher
|
- Node.js 22+ et pnpm 10.33.0+ pour les builds Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -178,8 +178,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installer le gestionnaire de paquets frontend déclaré par le dépôt
|
# Installer les dépendances frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compiler le binaire principal
|
# Compiler le binaire principal
|
||||||
make build
|
make build
|
||||||
|
|
@ -627,4 +627,3 @@ Discord : <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat :
|
WeChat :
|
||||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi
|
||||||
Prasyarat:
|
Prasyarat:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ dengan Corepack aktif untuk build Web UI / launcher
|
- Node.js 22+ dan pnpm 10.33.0+ untuk build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instal package manager frontend yang dideklarasikan repo
|
# Instal dependensi frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build binary inti
|
# Build binary inti
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi
|
||||||
Prerequisiti:
|
Prerequisiti:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ con Corepack abilitato per le build Web UI / launcher
|
- Node.js 22+ e pnpm 10.33.0+ per le build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Installa il package manager frontend dichiarato dal repository
|
# Installa le dipendenze frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compila il binario core
|
# Compila il binario core
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||||
前提条件:
|
前提条件:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Web UI / launcher のビルドには Corepack を有効にした Node.js 22+
|
- Web UI / launcher のビルドには Node.js 22+ と pnpm 10.33.0+ が必要
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# リポジトリで宣言されたフロントエンド用パッケージマネージャーをインストール
|
# フロントエンド依存関係をインストール
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# コアバイナリをビルド
|
# コアバイナリをビルド
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
|
||||||
필수 사항:
|
필수 사항:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Web UI / launcher 빌드를 위한 Corepack 활성화된 Node.js 22+
|
- Web UI / launcher 빌드에는 Node.js 22+와 pnpm 10.33.0+가 필요합니다
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 저장소에 선언된 프런트엔드 패키지 매니저 설치
|
# 프런트엔드 의존성 설치
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# 코어 바이너리 빌드
|
# 코어 바이너리 빌드
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -167,7 +167,7 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ with Corepack enabled for Web UI / launcher builds
|
- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Install frontend package manager declared by the repo
|
# Install frontend dependencies
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build the core binary for the current platform
|
# Build the core binary for the current platform
|
||||||
make build
|
make build
|
||||||
|
|
@ -523,7 +523,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configure ClawHub token** (optional, for higher rate limits):
|
**Configure skill registries**:
|
||||||
|
|
||||||
Add to your `config.json`:
|
Add to your `config.json`:
|
||||||
```json
|
```json
|
||||||
|
|
@ -533,6 +533,11 @@ Add to your `config.json`:
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "your-github-token",
|
||||||
|
"proxy": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -540,6 +545,8 @@ Add to your `config.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead.
|
||||||
|
|
||||||
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
|
||||||
|
|
@ -168,15 +168,15 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git
|
||||||
Prasyarat:
|
Prasyarat:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ dengan Corepack diaktifkan untuk binaan Web UI / launcher
|
- Node.js 22+ dan pnpm 10.33.0+ untuk binaan Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Pasang pengurus pakej frontend yang diisytiharkan oleh repositori
|
# Pasang dependensi frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Bina binari teras
|
# Bina binari teras
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel
|
||||||
Pré-requisitos:
|
Pré-requisitos:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ com Corepack habilitado para builds do Web UI / launcher
|
- Node.js 22+ e pnpm 10.33.0+ para builds do Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Instalar o gerenciador de pacotes de frontend declarado pelo repositório
|
# Instalar dependências do frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Compilar o binário principal
|
# Compilar o binário principal
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases
|
||||||
Yêu cầu:
|
Yêu cầu:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+ với Corepack được bật cho các bản build Web UI / launcher
|
- Node.js 22+ và pnpm 10.33.0+ cho các bản build Web UI / launcher
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# Cài đặt trình quản lý gói frontend được khai báo bởi repo
|
# Cài đặt dependencies frontend
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# Build binary lõi
|
# Build binary lõi
|
||||||
make build
|
make build
|
||||||
|
|
|
||||||
17
README.zh.md
17
README.zh.md
|
|
@ -167,7 +167,7 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
前置要求:
|
前置要求:
|
||||||
|
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Node.js 22+,并启用 Corepack(用于 Web UI / launcher 构建)
|
- Node.js 22+ 和 pnpm 10.33.0+(用于 Web UI / launcher 构建)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
|
|
@ -175,8 +175,8 @@ git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
make deps
|
make deps
|
||||||
|
|
||||||
# 安装仓库声明的前端包管理器
|
# 安装前端依赖
|
||||||
(cd web/frontend && corepack install)
|
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
# 构建核心二进制文件
|
# 构建核心二进制文件
|
||||||
make build
|
make build
|
||||||
|
|
@ -515,7 +515,7 @@ picoclaw skills search "web scraping"
|
||||||
picoclaw skills install <skill-name>
|
picoclaw skills install <skill-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
**配置 ClawHub token**(可选,用于提高速率限制):
|
**配置 Skills 仓库源**:
|
||||||
|
|
||||||
在 `config.json` 中添加:
|
在 `config.json` 中添加:
|
||||||
```json
|
```json
|
||||||
|
|
@ -525,6 +525,11 @@ picoclaw skills install <skill-name>
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"auth_token": "your-clawhub-token"
|
"auth_token": "your-clawhub-token"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "your-github-token",
|
||||||
|
"proxy": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -532,6 +537,8 @@ picoclaw skills install <skill-name>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。
|
||||||
|
|
||||||
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
|
||||||
|
|
||||||
## 🔗 MCP (Model Context Protocol)
|
## 🔗 MCP (Model Context Protocol)
|
||||||
|
|
@ -624,5 +631,3 @@ Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
WeChat:
|
WeChat:
|
||||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -36,6 +36,7 @@ type AggMetrics struct {
|
||||||
OverallHitRate float64 `json:"overallHitRate"`
|
OverallHitRate float64 `json:"overallHitRate"`
|
||||||
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||||
TotalQuestions int `json:"totalQuestions"`
|
TotalQuestions int `json:"totalQuestions"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatMetrics holds metrics for a single category.
|
// CatMetrics holds metrics for a single category.
|
||||||
|
|
@ -43,6 +44,7 @@ type CatMetrics struct {
|
||||||
F1 float64 `json:"f1"`
|
F1 float64 `json:"f1"`
|
||||||
HitRate float64 `json:"hitRate"`
|
HitRate float64 `json:"hitRate"`
|
||||||
QuestionCount int `json:"questionCount"`
|
QuestionCount int `json:"questionCount"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||||
|
|
@ -201,38 +203,64 @@ func EvalSeahorse(
|
||||||
|
|
||||||
// aggregateMetrics computes overall and per-category metrics.
|
// aggregateMetrics computes overall and per-category metrics.
|
||||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||||
byCat := map[int]*CatMetrics{}
|
type catAccum struct {
|
||||||
|
f1Sum float64
|
||||||
|
f1Count int
|
||||||
|
hitRateSum float64
|
||||||
|
hitRateCount int
|
||||||
|
}
|
||||||
|
byCatAcc := map[int]*catAccum{}
|
||||||
totalF1 := 0.0
|
totalF1 := 0.0
|
||||||
totalHitRate := 0.0
|
totalHitRate := 0.0
|
||||||
|
validF1Count := 0
|
||||||
for _, qr := range qaResults {
|
for _, qr := range qaResults {
|
||||||
totalF1 += qr.TokenF1
|
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
||||||
totalHitRate += qr.HitRate
|
if qr.TokenF1 >= 0 {
|
||||||
cat, ok := byCat[qr.Category]
|
totalF1 += qr.TokenF1
|
||||||
if !ok {
|
validF1Count++
|
||||||
cat = &CatMetrics{}
|
|
||||||
byCat[qr.Category] = cat
|
|
||||||
}
|
}
|
||||||
cat.F1 += qr.TokenF1
|
totalHitRate += qr.HitRate
|
||||||
cat.HitRate += qr.HitRate
|
acc, ok := byCatAcc[qr.Category]
|
||||||
cat.QuestionCount++
|
if !ok {
|
||||||
|
acc = &catAccum{}
|
||||||
|
byCatAcc[qr.Category] = acc
|
||||||
|
}
|
||||||
|
if qr.TokenF1 >= 0 {
|
||||||
|
acc.f1Sum += qr.TokenF1
|
||||||
|
acc.f1Count++
|
||||||
|
}
|
||||||
|
acc.hitRateSum += qr.HitRate
|
||||||
|
acc.hitRateCount++
|
||||||
}
|
}
|
||||||
n := len(qaResults)
|
nHit := len(qaResults)
|
||||||
if n == 0 {
|
if nHit == 0 {
|
||||||
n = 1
|
nHit = 1
|
||||||
}
|
}
|
||||||
agg := AggMetrics{
|
byCat := map[int]*CatMetrics{}
|
||||||
OverallF1: totalF1 / float64(n),
|
for cat, acc := range byCatAcc {
|
||||||
OverallHitRate: totalHitRate / float64(n),
|
cm := &CatMetrics{
|
||||||
|
QuestionCount: acc.hitRateCount,
|
||||||
|
ValidF1Count: acc.f1Count,
|
||||||
|
}
|
||||||
|
if acc.f1Count > 0 {
|
||||||
|
cm.F1 = acc.f1Sum / float64(acc.f1Count)
|
||||||
|
}
|
||||||
|
if acc.hitRateCount > 0 {
|
||||||
|
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
|
||||||
|
}
|
||||||
|
byCat[cat] = cm
|
||||||
|
}
|
||||||
|
var overallF1 float64
|
||||||
|
if validF1Count > 0 {
|
||||||
|
overallF1 = totalF1 / float64(validF1Count)
|
||||||
|
}
|
||||||
|
return AggMetrics{
|
||||||
|
OverallF1: overallF1,
|
||||||
|
OverallHitRate: totalHitRate / float64(nHit),
|
||||||
ByCategory: byCat,
|
ByCategory: byCat,
|
||||||
TotalQuestions: len(qaResults),
|
TotalQuestions: len(qaResults),
|
||||||
|
ValidF1Count: validF1Count,
|
||||||
}
|
}
|
||||||
for _, cat := range agg.ByCategory {
|
|
||||||
if cat.QuestionCount > 0 {
|
|
||||||
cat.F1 /= float64(cat.QuestionCount)
|
|
||||||
cat.HitRate /= float64(cat.QuestionCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveResults writes per-sample eval results to JSON files.
|
// SaveResults writes per-sample eval results to JSON files.
|
||||||
|
|
@ -277,27 +305,43 @@ func SaveAggregated(results []EvalResult, outDir string) error {
|
||||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
|
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
|
||||||
|
// LLM modes may legitimately have ValidF1Count==0 (all failures).
|
||||||
|
vf1 := r.Agg.ValidF1Count
|
||||||
|
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
vf1 = r.Agg.TotalQuestions
|
||||||
|
}
|
||||||
|
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
|
||||||
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||||
agg.TotalQuestions += r.Agg.TotalQuestions
|
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||||
|
agg.ValidF1Count += vf1
|
||||||
for cat, cm := range r.Agg.ByCategory {
|
for cat, cm := range r.Agg.ByCategory {
|
||||||
existing, ok := agg.ByCategory[cat]
|
existing, ok := agg.ByCategory[cat]
|
||||||
if !ok {
|
if !ok {
|
||||||
existing = &CatMetrics{}
|
existing = &CatMetrics{}
|
||||||
agg.ByCategory[cat] = existing
|
agg.ByCategory[cat] = existing
|
||||||
}
|
}
|
||||||
existing.F1 += cm.F1 * float64(cm.QuestionCount)
|
cvf1 := cm.ValidF1Count
|
||||||
|
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
cvf1 = cm.QuestionCount
|
||||||
|
}
|
||||||
|
existing.F1 += cm.F1 * float64(cvf1)
|
||||||
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||||
existing.QuestionCount += cm.QuestionCount
|
existing.QuestionCount += cm.QuestionCount
|
||||||
|
existing.ValidF1Count += cvf1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if agg.ValidF1Count > 0 {
|
||||||
|
agg.OverallF1 /= float64(agg.ValidF1Count)
|
||||||
|
}
|
||||||
if agg.TotalQuestions > 0 {
|
if agg.TotalQuestions > 0 {
|
||||||
agg.OverallF1 /= float64(agg.TotalQuestions)
|
|
||||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||||
}
|
}
|
||||||
for _, cat := range agg.ByCategory {
|
for _, cat := range agg.ByCategory {
|
||||||
|
if cat.ValidF1Count > 0 {
|
||||||
|
cat.F1 /= float64(cat.ValidF1Count)
|
||||||
|
}
|
||||||
if cat.QuestionCount > 0 {
|
if cat.QuestionCount > 0 {
|
||||||
cat.F1 /= float64(cat.QuestionCount)
|
|
||||||
cat.HitRate /= float64(cat.QuestionCount)
|
cat.HitRate /= float64(cat.QuestionCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +403,9 @@ func printSection(title string, results []EvalResult) {
|
||||||
|
|
||||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||||
printSection("No LLM generation", results)
|
if len(results) > 0 {
|
||||||
|
printSection("No LLM generation", results)
|
||||||
|
}
|
||||||
if len(llmResults) > 0 {
|
if len(llmResults) > 0 {
|
||||||
printSection("With LLM", llmResults)
|
printSection("With LLM", llmResults)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
346
cmd/membench/eval_llm.go
Normal file
346
cmd/membench/eval_llm.go
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.`
|
||||||
|
|
||||||
|
const judgeSystemPrompt = `You are an impartial judge evaluating answer quality.
|
||||||
|
Compare the candidate answer against the reference answer.
|
||||||
|
Consider semantic equivalence — different wording expressing the same meaning should score high.
|
||||||
|
|
||||||
|
Output ONLY a single integer score from 1 to 5:
|
||||||
|
1 = completely wrong or irrelevant
|
||||||
|
2 = partially related but mostly incorrect
|
||||||
|
3 = partially correct, missing key details
|
||||||
|
4 = mostly correct with minor omissions
|
||||||
|
5 = fully correct, semantically equivalent
|
||||||
|
|
||||||
|
Output ONLY the number, nothing else.`
|
||||||
|
|
||||||
|
// generateAnswer asks the LLM to answer a question given retrieved context.
|
||||||
|
func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) {
|
||||||
|
// Truncate context to avoid exceeding model limits while preserving valid UTF-8.
|
||||||
|
contextRunes := []rune(contextText)
|
||||||
|
if len(contextRunes) > 6000 {
|
||||||
|
contextText = string(contextRunes[:6000]) + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question)
|
||||||
|
return client.Complete(ctx, answerSystemPrompt, userPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreRe matches the first standalone integer 1-5 in the judge response.
|
||||||
|
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
|
||||||
|
|
||||||
|
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
|
||||||
|
// Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
|
||||||
|
func judgeAnswer(
|
||||||
|
ctx context.Context,
|
||||||
|
judgeClient *LLMClient,
|
||||||
|
question, goldAnswer, candidateAnswer string,
|
||||||
|
) (float64, error) {
|
||||||
|
userPrompt := fmt.Sprintf(
|
||||||
|
"Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:",
|
||||||
|
question, goldAnswer, candidateAnswer,
|
||||||
|
)
|
||||||
|
|
||||||
|
response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return -1.0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
|
||||||
|
score, _ := strconv.Atoi(m[1])
|
||||||
|
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
|
||||||
|
}
|
||||||
|
log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
|
||||||
|
return -1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaWork describes one QA evaluation unit.
|
||||||
|
type qaWork struct {
|
||||||
|
sampleID string
|
||||||
|
qaIndex int
|
||||||
|
globalIndex int
|
||||||
|
totalQA int
|
||||||
|
qa *LocomoQA
|
||||||
|
contextText string
|
||||||
|
sample *LocomoSample
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaResult collects one QA evaluation output.
|
||||||
|
type qaResultOut struct {
|
||||||
|
index int // position in the flat QA list for ordering
|
||||||
|
result QAResult
|
||||||
|
answer string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalQAWorker processes a single QA item: generate answer + judge score.
|
||||||
|
func evalQAWorker(
|
||||||
|
ctx context.Context,
|
||||||
|
w qaWork,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
logPrefix string,
|
||||||
|
) qaResultOut {
|
||||||
|
llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
llmAnswer = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
score := -1.0
|
||||||
|
if llmAnswer != "" {
|
||||||
|
score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText)
|
||||||
|
|
||||||
|
log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q",
|
||||||
|
logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80))
|
||||||
|
|
||||||
|
return qaResultOut{
|
||||||
|
index: w.globalIndex,
|
||||||
|
result: QAResult{
|
||||||
|
Question: w.qa.Question,
|
||||||
|
Category: w.qa.Category,
|
||||||
|
GoldAnswer: w.qa.AnswerString(),
|
||||||
|
TokenF1: score,
|
||||||
|
HitRate: hitRate,
|
||||||
|
},
|
||||||
|
answer: llmAnswer,
|
||||||
|
score: score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalLegacyLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
legacy *LegacyStore,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
history := legacy.GetHistory(sample.SampleID)
|
||||||
|
|
||||||
|
allContent := make([]string, 0, len(history))
|
||||||
|
for _, msg := range history {
|
||||||
|
allContent = append(allContent, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||||
|
contextText := StringListToContent(truncated)
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result // safe: each goroutine writes distinct index
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "legacy-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSeahorseContext retrieves context for a seahorse QA item.
|
||||||
|
func buildSeahorseContext(
|
||||||
|
ctx context.Context,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
sample *LocomoSample,
|
||||||
|
qa *LocomoQA,
|
||||||
|
budgetTokens int,
|
||||||
|
) string {
|
||||||
|
store := ir.Engine.GetRetrieval().Store()
|
||||||
|
retrieval := ir.Engine.GetRetrieval()
|
||||||
|
convID := ir.ConvMap[sample.SampleID]
|
||||||
|
|
||||||
|
keywords := ExtractKeywords(qa.Question)
|
||||||
|
bestRank := map[int64]float64{}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||||
|
Pattern: kw,
|
||||||
|
ConversationID: convID,
|
||||||
|
Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sr := range searchResults {
|
||||||
|
if sr.MessageID > 0 {
|
||||||
|
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||||
|
bestRank[sr.MessageID] = sr.Rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIDs := make([]int64, 0, len(bestRank))
|
||||||
|
for id := range bestRank {
|
||||||
|
messageIDs = append(messageIDs, id)
|
||||||
|
}
|
||||||
|
sort.Slice(messageIDs, func(i, j int) bool {
|
||||||
|
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||||
|
})
|
||||||
|
|
||||||
|
var contentParts []string
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||||
|
if err == nil {
|
||||||
|
for _, msg := range expandResult.Messages {
|
||||||
|
contentParts = append(contentParts, msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(contentParts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||||
|
return StringListToContent(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalSeahorseLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
if _, ok := ir.ConvMap[sample.SampleID]; !ok {
|
||||||
|
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
evalOne := func(qi int) {
|
||||||
|
qa := &sample.QA[qi]
|
||||||
|
contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens)
|
||||||
|
if contextText == "" {
|
||||||
|
qaResults[qi] = QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: 0.0,
|
||||||
|
HitRate: 0.0,
|
||||||
|
}
|
||||||
|
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)",
|
||||||
|
sample.SampleID, si*len(sample.QA)+qi+1, totalQA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: qa, contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "seahorse-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
evalOne(qi)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
evalOne(qi)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "seahorse-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalQA(samples []LocomoSample) int {
|
||||||
|
n := 0
|
||||||
|
for i := range samples {
|
||||||
|
n += len(samples[i].QA)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > maxLen {
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -102,3 +102,81 @@ func TestComputeModeAgg(t *testing.T) {
|
||||||
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 2 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.TotalQuestions != 3 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
|
||||||
|
}
|
||||||
|
wantF1 := (0.8 + 0.4) / 2.0
|
||||||
|
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
wantHR := (0.5 + 0.3 + 0.7) / 3.0
|
||||||
|
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
|
||||||
|
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsAllSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 0 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.OverallF1 != 0 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeModeAggSentinelWeighting(t *testing.T) {
|
||||||
|
results := []EvalResult{
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s1",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s2",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
|
||||||
|
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := computeModeAgg(results)
|
||||||
|
|
||||||
|
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
|
||||||
|
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
|
||||||
|
wantF1 := 0.6
|
||||||
|
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
if got.ValidF1Count != 3 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
|
||||||
|
}
|
||||||
|
if got.TotalQuestions != 4 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
198
cmd/membench/llm_client.go
Normal file
198
cmd/membench/llm_client.go
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMClient wraps an OpenAI-compatible chat completion endpoint.
|
||||||
|
type LLMClient struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
|
||||||
|
MaxRetries int // max retry attempts for transient errors (0 = no retry)
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClientOptions configures the LLM client.
|
||||||
|
type LLMClientOptions struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
Timeout time.Duration
|
||||||
|
NoThinking bool
|
||||||
|
MaxRetries int // max retry attempts (default 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMClient creates a client for an OpenAI-compatible chat completion API.
|
||||||
|
func NewLLMClient(opts LLMClientOptions) *LLMClient {
|
||||||
|
if opts.Timeout == 0 {
|
||||||
|
opts.Timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
maxRetries := opts.MaxRetries
|
||||||
|
if maxRetries < 0 {
|
||||||
|
maxRetries = 3
|
||||||
|
}
|
||||||
|
return &LLMClient{
|
||||||
|
BaseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
Model: opts.Model,
|
||||||
|
APIKey: opts.APIKey,
|
||||||
|
NoThinking: opts.NoThinking,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
Client: &http.Client{
|
||||||
|
Timeout: opts.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp
|
||||||
|
Think *bool `json:"think,omitempty"` // Ollama
|
||||||
|
Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a chat completion request and returns the assistant's reply.
|
||||||
|
func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
sysContent := systemPrompt
|
||||||
|
if c.NoThinking && sysContent != "" {
|
||||||
|
// Prepend /no_think tag — works with Ollama /v1 endpoint and
|
||||||
|
// Qwen chat templates where the JSON think field is ignored.
|
||||||
|
sysContent = "/no_think\n" + sysContent
|
||||||
|
}
|
||||||
|
messages := []chatMessage{}
|
||||||
|
if sysContent != "" {
|
||||||
|
messages = append(messages, chatMessage{Role: "system", Content: sysContent})
|
||||||
|
}
|
||||||
|
messages = append(messages, chatMessage{Role: "user", Content: userPrompt})
|
||||||
|
|
||||||
|
body := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: 0.1,
|
||||||
|
MaxTokens: 512,
|
||||||
|
}
|
||||||
|
if c.NoThinking {
|
||||||
|
// llama.cpp: chat_template_kwargs
|
||||||
|
body.ChatTemplateKwargs = map[string]any{
|
||||||
|
"enable_thinking": false,
|
||||||
|
}
|
||||||
|
// Ollama (0.9+): think field
|
||||||
|
thinkFalse := false
|
||||||
|
body.Think = &thinkFalse
|
||||||
|
// GLM (智谱): thinking field
|
||||||
|
body.Thinking = map[string]any{
|
||||||
|
"type": "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var respBody []byte
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
|
||||||
|
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
// Rebuild request (body reader is consumed)
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
resp, lastErr = c.Client.Do(req)
|
||||||
|
if lastErr != nil {
|
||||||
|
continue // network/timeout error → retry
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, lastErr = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if lastErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||||
|
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
continue // rate limit or server error → retry
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp chatResponse
|
||||||
|
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no choices in response")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||||
|
// Strip any residual <think>...</think> blocks
|
||||||
|
if idx := strings.Index(content, "</think>"); idx >= 0 {
|
||||||
|
content = strings.TrimSpace(content[idx+len("</think>"):])
|
||||||
|
}
|
||||||
|
// Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled
|
||||||
|
if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" {
|
||||||
|
content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return "", fmt.Errorf("empty LLM response")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
|
@ -15,10 +16,22 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
flagData string
|
flagData string
|
||||||
flagOut string
|
flagOut string
|
||||||
flagMode string
|
flagMode string
|
||||||
flagBudget int
|
flagBudget int
|
||||||
|
flagEvalMode string
|
||||||
|
flagAPIBase string
|
||||||
|
flagAPIKey string
|
||||||
|
flagModel string
|
||||||
|
flagNoThinking bool
|
||||||
|
flagLimit int
|
||||||
|
flagTimeout int
|
||||||
|
flagRetries int
|
||||||
|
flagJudgeModel string
|
||||||
|
flagJudgeAPIBase string
|
||||||
|
flagJudgeAPIKey string
|
||||||
|
flagConcurrency int
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -48,6 +61,22 @@ func main() {
|
||||||
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||||
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
reportCmd := &cobra.Command{
|
reportCmd := &cobra.Command{
|
||||||
Use: "report",
|
Use: "report",
|
||||||
|
|
@ -65,6 +94,22 @@ func main() {
|
||||||
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||||
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
runCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||||
|
|
||||||
|
|
@ -136,7 +181,50 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
log.Printf("Loaded %d samples", len(samples))
|
log.Printf("Loaded %d samples", len(samples))
|
||||||
|
|
||||||
var allResults []EvalResult
|
if flagLimit > 0 {
|
||||||
|
for i := range samples {
|
||||||
|
if len(samples[i].QA) > flagLimit {
|
||||||
|
samples[i].QA = samples[i].QA[:flagLimit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Limited to %d QA per sample", flagLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
|
||||||
|
var useLLM bool
|
||||||
|
switch evalMode {
|
||||||
|
case "token":
|
||||||
|
useLLM = false
|
||||||
|
case "llm":
|
||||||
|
useLLM = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
|
||||||
|
}
|
||||||
|
var answerClient, judgeClient *LLMClient
|
||||||
|
if useLLM {
|
||||||
|
opts, err := buildLLMOptions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
answerClient = NewLLMClient(opts)
|
||||||
|
judgeClient = answerClient // default: same client
|
||||||
|
if flagJudgeModel != "" {
|
||||||
|
jOpts := opts // copy base settings
|
||||||
|
jOpts.Model = flagJudgeModel
|
||||||
|
if flagJudgeAPIBase != "" {
|
||||||
|
jOpts.BaseURL = flagJudgeAPIBase
|
||||||
|
}
|
||||||
|
if flagJudgeAPIKey != "" {
|
||||||
|
jOpts.APIKey = flagJudgeAPIKey
|
||||||
|
}
|
||||||
|
judgeClient = NewLLMClient(jOpts)
|
||||||
|
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
|
||||||
|
}
|
||||||
|
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
|
||||||
|
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResults, llmResults []EvalResult
|
||||||
|
|
||||||
for _, mode := range modes {
|
for _, mode := range modes {
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|
@ -145,21 +233,34 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
legacy.IngestSample(&samples[i])
|
legacy.IngestSample(&samples[i])
|
||||||
}
|
}
|
||||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
if useLLM {
|
||||||
allResults = append(allResults, results...)
|
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
log.Printf("legacy: evaluated %d samples", len(results))
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("legacy-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
|
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||||
|
tokenResults = append(tokenResults, results...)
|
||||||
|
log.Printf("legacy: evaluated %d samples", len(results))
|
||||||
|
}
|
||||||
case "seahorse":
|
case "seahorse":
|
||||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||||
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ingest seahorse: %w", err)
|
return fmt.Errorf("ingest seahorse: %w", err)
|
||||||
}
|
}
|
||||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
if useLLM {
|
||||||
allResults = append(allResults, results...)
|
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("seahorse-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
|
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||||
|
tokenResults = append(tokenResults, results...)
|
||||||
|
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allResults := append(tokenResults, llmResults...)
|
||||||
if err := SaveResults(allResults, flagOut); err != nil {
|
if err := SaveResults(allResults, flagOut); err != nil {
|
||||||
return fmt.Errorf("save results: %w", err)
|
return fmt.Errorf("save results: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +268,7 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("save aggregated: %w", err)
|
return fmt.Errorf("save aggregated: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintComparison(allResults, nil)
|
PrintComparison(tokenResults, llmResults)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +300,62 @@ func runReport(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrintComparison(allResults, nil)
|
var tokenResults, llmResults []EvalResult
|
||||||
|
for _, r := range allResults {
|
||||||
|
if strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
llmResults = append(llmResults, r)
|
||||||
|
} else {
|
||||||
|
tokenResults = append(tokenResults, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintComparison(tokenResults, llmResults)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAll(cmd *cobra.Command, args []string) error {
|
func runAll(cmd *cobra.Command, args []string) error {
|
||||||
return runEval(cmd, args)
|
return runEval(cmd, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
|
||||||
|
// environment variable.
|
||||||
|
func envOrFlag(flag, envKey string) string {
|
||||||
|
if flag != "" {
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
return os.Getenv(envKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLLMOptions resolves LLM client configuration from flags and environment
|
||||||
|
// variables. Flag values take precedence over environment variables.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
//
|
||||||
|
// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
||||||
|
// MEMBENCH_API_KEY – Bearer token for the endpoint
|
||||||
|
// MEMBENCH_MODEL – Model name to send in the request
|
||||||
|
func buildLLMOptions() (LLMClientOptions, error) {
|
||||||
|
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
|
||||||
|
if base == "" {
|
||||||
|
base = "http://127.0.0.1:8080/v1"
|
||||||
|
}
|
||||||
|
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf(
|
||||||
|
"--model or MEMBENCH_MODEL is required for LLM eval mode",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
|
||||||
|
|
||||||
|
if flagTimeout <= 0 {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMClientOptions{
|
||||||
|
BaseURL: base,
|
||||||
|
Model: model,
|
||||||
|
APIKey: apiKey,
|
||||||
|
NoThinking: flagNoThinking,
|
||||||
|
Timeout: time.Duration(flagTimeout) * time.Second,
|
||||||
|
MaxRetries: flagRetries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -155,11 +156,31 @@ func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||||
cfg.Channels.WeCom.Enabled = true
|
bc := cfg.Channels.GetByType(config.ChannelWeCom)
|
||||||
cfg.Channels.WeCom.BotID = botInfo.BotID
|
if bc == nil {
|
||||||
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
bc = &config.Channel{Type: config.ChannelWeCom}
|
||||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
cfg.Channels["wecom"] = bc
|
||||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
}
|
||||||
|
bc.Enabled = true
|
||||||
|
|
||||||
|
decoded, err := bc.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wecomCfg, ok := decoded.(*config.WeComSettings)
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{
|
||||||
|
"got": fmt.Sprintf("%T", decoded),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wecomCfg.BotID = botInfo.BotID
|
||||||
|
wecomCfg.Secret = *config.NewSecureString(botInfo.Secret)
|
||||||
|
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
|
||||||
|
wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,17 +112,23 @@ func TestPollWeComQRCodeResult(t *testing.T) {
|
||||||
|
|
||||||
func TestApplyWeComAuthResult(t *testing.T) {
|
func TestApplyWeComAuthResult(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Channels.WeCom.WebSocketURL = ""
|
require.NoError(t, config.InitChannelList(cfg.Channels))
|
||||||
|
wecom := cfg.Channels["wecom"]
|
||||||
|
t.Logf("wecom: %+v", wecom)
|
||||||
|
decoded, err := wecom.GetDecoded()
|
||||||
|
require.NoError(t, err)
|
||||||
|
weCfg := decoded.(*config.WeComSettings)
|
||||||
|
weCfg.WebSocketURL = ""
|
||||||
|
|
||||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||||
BotID: "bot-1",
|
BotID: "bot-1",
|
||||||
Secret: "secret-1",
|
Secret: "secret-1",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
assert.True(t, wecom.Enabled)
|
||||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
|
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
|
|
@ -149,9 +155,13 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
wecom := cfg.Channels["wecom"]
|
||||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
decoded, err := wecom.GetDecoded()
|
||||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
|
require.NoError(t, err)
|
||||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
weCfg := decoded.(*config.WeComSettings)
|
||||||
|
assert.True(t, wecom.Enabled)
|
||||||
|
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||||
|
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||||
|
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||||
assert.Contains(t, output.String(), "WeCom connected.")
|
assert.Contains(t, output.String(), "WeCom connected.")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,24 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Channels.Weixin.Enabled = true
|
bc := cfg.Channels.GetByType(config.ChannelWeixin)
|
||||||
cfg.Channels.Weixin.SetToken(token)
|
if bc == nil {
|
||||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
bc = &config.Channel{Type: config.ChannelWeixin}
|
||||||
if baseURL != "" && baseURL != defaultBase {
|
cfg.Channels[config.ChannelWeixin] = bc
|
||||||
cfg.Channels.Weixin.BaseURL = baseURL
|
|
||||||
}
|
}
|
||||||
if proxy != "" {
|
bc.Enabled = true
|
||||||
cfg.Channels.Weixin.Proxy = proxy
|
|
||||||
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||||
|
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
|
||||||
|
weixinCfg.Token = *config.NewSecureString(token)
|
||||||
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
weixinCfg.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
if proxy != "" {
|
||||||
|
weixinCfg.Proxy = proxy
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config.SaveConfig(cfgPath, cfg)
|
return config.SaveConfig(cfgPath, cfg)
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,34 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
|
||||||
|
if !explicit {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
normalized, err := netbind.NormalizeHostInput(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid --host value: %w", err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
var allowEmpty bool
|
var allowEmpty bool
|
||||||
|
var host string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
|
|
@ -33,7 +48,25 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolvedHost != "" {
|
||||||
|
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
||||||
|
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
|
||||||
|
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if hadPrev {
|
||||||
|
_ = os.Setenv(config.EnvGatewayHost, prevHost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Unsetenv(config.EnvGatewayHost)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +80,12 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
false,
|
false,
|
||||||
"Continue starting even when no default model is configured",
|
"Continue starting even when no default model is configured",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&host,
|
||||||
|
"host",
|
||||||
|
"",
|
||||||
|
"Host address for gateway binding (overrides gateway.host for this run)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,38 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayHostOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicit bool
|
||||||
|
host string
|
||||||
|
wantHost string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
|
||||||
|
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
|
||||||
|
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
|
||||||
|
{
|
||||||
|
name: "explicit multi host normalized",
|
||||||
|
explicit: true,
|
||||||
|
host: " [::1] , 127.0.0.1 ",
|
||||||
|
wantHost: "::1,127.0.0.1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.wantHost {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
}
|
}
|
||||||
|
if new_path == "AGENTS.md" || new_path == "IDENTITY.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build target file path
|
// Build target file path
|
||||||
targetPath := filepath.Join(targetDir, new_path)
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
type deps struct {
|
type deps struct {
|
||||||
workspace string
|
workspace string
|
||||||
installer *skills.SkillInstaller
|
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,15 +28,6 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
|
||||||
d.workspace,
|
|
||||||
cfg.Tools.Skills.Github.Token.String(),
|
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating skills installer: %w", err)
|
|
||||||
}
|
|
||||||
d.installer = installer
|
|
||||||
|
|
||||||
// get global config directory and builtin skills directory
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
|
|
@ -52,13 +42,6 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
installerFn := func() (*skills.SkillInstaller, error) {
|
|
||||||
if d.installer == nil {
|
|
||||||
return nil, fmt.Errorf("skills installer is not initialized")
|
|
||||||
}
|
|
||||||
return d.installer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
if d.skillsLoader == nil {
|
if d.skillsLoader == nil {
|
||||||
return nil, fmt.Errorf("skills loader is not initialized")
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
|
|
@ -75,10 +58,10 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newListCommand(loaderFn),
|
newListCommand(loaderFn),
|
||||||
newInstallCommand(installerFn),
|
newInstallCommand(),
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(),
|
||||||
newSearchCommand(),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -11,12 +12,23 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const skillsSearchMaxResults = 20
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
|
type installedSkillOriginMeta struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
OriginKind string `json:"origin_kind,omitempty"`
|
||||||
|
Registry string `json:"registry,omitempty"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
RegistryURL string `json:"registry_url,omitempty"`
|
||||||
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
|
InstalledAt int64 `json:"installed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
|
|
@ -35,61 +47,32 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
|
||||||
return fmt.Errorf("failed to install skill: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = utils.ValidateSkillIdentifier(slug)
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
|
||||||
|
|
||||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig{
|
|
||||||
Enabled: clawHubConfig.Enabled,
|
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
|
||||||
AuthToken: clawHubConfig.AuthToken.String(),
|
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
|
||||||
Timeout: clawHubConfig.Timeout,
|
|
||||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
|
||||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dirName, err := registry.ResolveInstallDirName(target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("✗ invalid install target %q: %w", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName)
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
targetDir := filepath.Join(workspace, "skills", dirName)
|
||||||
|
|
||||||
if _, err = os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
|
@ -99,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rmErr := os.RemoveAll(targetDir)
|
rmErr := os.RemoveAll(targetDir)
|
||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
|
|
@ -114,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
if !workspaceHasValidSkillDirectory(workspace, dirName) {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version)
|
||||||
|
installedAt := time.Now().UnixMilli()
|
||||||
|
if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{
|
||||||
|
Version: 1,
|
||||||
|
OriginKind: "third_party",
|
||||||
|
Registry: registry.Name(),
|
||||||
|
Slug: normalizedSlug,
|
||||||
|
RegistryURL: registryURL,
|
||||||
|
InstalledVersion: result.Version,
|
||||||
|
InstalledAt: installedAt,
|
||||||
|
}); err != nil {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to persist skill metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version)
|
||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
|
|
@ -129,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
|
||||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
if err := installer.Uninstall(skillName); err != nil {
|
return err
|
||||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
func workspaceHasValidSkillDirectory(workspace, directory string) bool {
|
||||||
|
loader := skills.NewSkillsLoader(workspace, "", "")
|
||||||
|
for _, skill := range loader.ListSkills() {
|
||||||
|
if skill.Source != "workspace" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Base(filepath.Dir(skill.Path)) == directory {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error {
|
||||||
|
name := strings.TrimSpace(skillName)
|
||||||
|
name = strings.Trim(name, "/")
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("skill name is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name)
|
||||||
|
if err != nil || dirName == "" {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
name = dirName
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
skillDir := filepath.Join(workspace, "skills", name)
|
||||||
|
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("skill '%s' not found", name)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(skillDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove skill '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallBuiltinCmd(workspace string) {
|
func skillsInstallBuiltinCmd(workspace string) {
|
||||||
|
|
@ -237,21 +276,7 @@ func skillsSearchCmd(query string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig{
|
|
||||||
Enabled: clawHubConfig.Enabled,
|
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
|
||||||
AuthToken: clawHubConfig.AuthToken.String(),
|
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
|
||||||
Timeout: clawHubConfig.Timeout,
|
|
||||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
|
||||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
191
cmd/picoclaw/internal/skills/helpers_test.go
Normal file
191
cmd/picoclaw/internal/skills/helpers_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
assert.Equal(t, "ref=master", r.URL.RawQuery)
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
require.NoError(t, skillsInstallFromRegistry(cfg, "github", target))
|
||||||
|
|
||||||
|
metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")
|
||||||
|
data, err := os.ReadFile(metaPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var meta installedSkillOriginMeta
|
||||||
|
require.NoError(t, json.Unmarshal(data, &meta))
|
||||||
|
assert.Equal(t, "third_party", meta.OriginKind)
|
||||||
|
assert.Equal(t, "github", meta.Registry)
|
||||||
|
assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug)
|
||||||
|
assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL)
|
||||||
|
assert.Equal(t, "master", meta.InstalledVersion)
|
||||||
|
assert.NotZero(t, meta.InstalledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
err := skillsInstallFromRegistry(cfg, "github", target)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "is not a valid skill")
|
||||||
|
_, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
skillsDir := filepath.Join(workspace, "skills")
|
||||||
|
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid skill name")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(skillsDir)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
_, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt"))
|
||||||
|
assert.NoError(t, fileErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "bar")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = "https://ghe.example.com/git"
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.Enabled = false
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
@ -6,15 +6,14 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newInstallCommand() *cobra.Command {
|
||||||
var registry string
|
var registry string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub",
|
Short: "Install skill from GitHub or a registry",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
|
|
@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
cfg, err := internal.LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallFromRegistry(cfg, "github", args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewInstallSubcommand(t *testing.T) {
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "install", cmd.Use)
|
assert.Equal(t, "install", cmd.Use)
|
||||||
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
if tt.registry != "" {
|
if tt.registry != "" {
|
||||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package skills
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newRemoveCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove",
|
Use: "remove",
|
||||||
Aliases: []string{"rm", "uninstall"},
|
Aliases: []string{"rm", "uninstall"},
|
||||||
|
|
@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Example: `picoclaw skills remove weather`,
|
Example: `picoclaw skills remove weather`,
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
skillsRemoveCmd(installer, args[0])
|
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRemoveSubcommand(t *testing.T) {
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
cmd := newRemoveCommand(nil)
|
cmd := newRemoveCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,10 +269,15 @@
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"max_results": 0
|
"max_results": 0
|
||||||
},
|
},
|
||||||
"duckduckgo": {
|
"provider": "auto",
|
||||||
|
"sogou": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": false,
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
|
|
@ -382,9 +387,16 @@
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"max_zip_size": 0,
|
"max_zip_size": 0,
|
||||||
"max_response_size": 0
|
"max_response_size": 0
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "https://github.com",
|
||||||
|
"auth_token": "",
|
||||||
|
"proxy": "http://127.0.0.1:7891"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
|
"base_url": "https://github.com",
|
||||||
"proxy": "http://127.0.0.1:7891",
|
"proxy": "http://127.0.0.1:7891",
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
|
@ -465,7 +477,7 @@
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||||
"host": "127.0.0.1",
|
"host": "localhost",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"hot_reload": false,
|
"hot_reload": false,
|
||||||
"log_level": "fatal"
|
"log_level": "fatal"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ DingTalk est la plateforme de communication d'entreprise d'Alibaba, très popula
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ DingTalkはアリババの企業向けコミュニケーションプラットフ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ DingTalk is Alibaba's enterprise communication platform, widely used in Chinese
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente uti
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được s
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord est une application gratuite de chat vocal, vidéo et textuel conçue po
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord はコミュニティ向けに設計された無料の音声・ビデオ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord is a free voice, video, and text chat application designed for communiti
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord là ứng dụng chat thoại, video và văn bản miễn phí được
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Feishu (nom international : Lark) est une plateforme de collaboration d'entrepri
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp củ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "feishu",
|
||||||
"app_id": "cli_xxx",
|
"app_id": "cli_xxx",
|
||||||
"app_secret": "xxx",
|
"app_secret": "xxx",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhoo
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw supports LINE through the LINE Messaging API with webhook callbacks.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhoo
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "line",
|
||||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et M
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed Mai
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam là kênh chuyên dụng để kết nối với các thiết bị camer
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "maixcam",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Ajoutez ceci à `config.json` :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Add this to `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Adicione isto ao `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Thêm vào `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"matrix": {
|
"matrix": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "matrix",
|
||||||
"homeserver": "https://matrix.org",
|
"homeserver": "https://matrix.org",
|
||||||
"user_id": "@your-bot:matrix.org",
|
"user_id": "@your-bot:matrix.org",
|
||||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une int
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot は QQ ボット向けのオープンプロトコル標準で、複数の
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot is an open protocol standard for QQ bots, providing a unified interface f
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao di
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "onebot",
|
||||||
"ws_url": "ws://localhost:8080",
|
"ws_url": "ws://localhost:8080",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw provides QQ support via the official Bot API from the QQ Open Platform.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ O PicoClaw oferece suporte ao QQ via API Bot oficial da Plataforma Aberta QQ.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw hỗ trợ QQ thông qua API Bot chính thức của Nền tảng Mở
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "qq",
|
||||||
"app_id": "YOUR_APP_ID",
|
"app_id": "YOUR_APP_ID",
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
"app_secret": "YOUR_APP_SECRET",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Slack est l'une des principales plateformes de messagerie instantanée pour les
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Slack は世界をリードする企業向けインスタントメッセージ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Slack is a leading enterprise instant messaging platform. PicoClaw uses Slack's
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ O Slack é uma das principais plataformas de mensagens instantâneas para empres
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Slack là nền tảng nhắn tin tức thì hàng đầu dành cho doanh nghi
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "slack",
|
||||||
"bot_token": "xoxb-...",
|
"bot_token": "xoxb-...",
|
||||||
"app_token": "xapp-...",
|
"app_token": "xapp-...",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -42,9 +43,10 @@ Vous pouvez définir `use_markdown_v2: true` pour activer les options de formata
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -42,9 +43,10 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -62,9 +63,10 @@ You can set `use_markdown_v2: true` to enable enhanced formatting options. This
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -42,9 +43,10 @@ Você pode definir `use_markdown_v2: true` para habilitar opções de formataç
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -42,9 +43,10 @@ Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn đ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
|
|
@ -62,9 +63,10 @@ explain how to squash the last 3 commits
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "telegram",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"use_markdown_v2": true
|
"use_markdown_v2": true
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ The VK channel uses Bots Long Poll API for bot-based communication with VK socia
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"vk": {
|
"vk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "vk",
|
||||||
"token": "NOT_HERE",
|
"token": "NOT_HERE",
|
||||||
"group_id": 123456789,
|
"group_id": 123456789,
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
|
|
@ -120,9 +121,10 @@ VK has a maximum message length of 4000 characters. PicoClaw automatically split
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"vk": {
|
"vk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "vk",
|
||||||
"token": "NOT_HERE",
|
"token": "NOT_HERE",
|
||||||
"group_id": 123456789
|
"group_id": 123456789
|
||||||
}
|
}
|
||||||
|
|
@ -134,9 +136,10 @@ VK has a maximum message length of 4000 characters. PicoClaw automatically split
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"vk": {
|
"vk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "vk",
|
||||||
"token": "NOT_HERE",
|
"token": "NOT_HERE",
|
||||||
"group_id": 123456789,
|
"group_id": 123456789,
|
||||||
"allow_from": ["123456789", "987654321"]
|
"allow_from": ["123456789", "987654321"]
|
||||||
|
|
@ -149,9 +152,10 @@ VK has a maximum message length of 4000 characters. PicoClaw automatically split
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"vk": {
|
"vk": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "vk",
|
||||||
"token": "NOT_HERE",
|
"token": "NOT_HERE",
|
||||||
"group_id": 123456789,
|
"group_id": 123456789,
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ Si vous disposez déjà d'un `bot_id` et d'un `secret` depuis la plateforme WeCo
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ WeCom AI Bot プラットフォームから `bot_id` と `secret` を既にお
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ If you already have a `bot_id` and `secret` from the WeCom AI Bot platform, conf
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ Se você já possui um `bot_id` e `secret` da plataforma WeCom AI Bot, configure
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ Nếu bạn đã có `bot_id` và `secret` từ nền tảng WeCom AI Bot, hãy
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ picoclaw auth wecom --timeout 10m
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"channels": {
|
"channel_list": {
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"type": "wecom",
|
||||||
"bot_id": "YOUR_BOT_ID",
|
"bot_id": "YOUR_BOT_ID",
|
||||||
"secret": "YOUR_SECRET",
|
"secret": "YOUR_SECRET",
|
||||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue