Merge branch 'main' into feat/suppoer_btw_command
This commit is contained in:
commit
0ea7084998
59 changed files with 3470 additions and 155 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -16,5 +16,5 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
- name: Build core binaries
|
||||
run: make build-all
|
||||
|
|
|
|||
24
.github/workflows/create_dmg.yml
vendored
24
.github/workflows/create_dmg.yml
vendored
|
|
@ -17,29 +17,35 @@ jobs:
|
|||
with:
|
||||
ref: main
|
||||
|
||||
# 1. 安装指定版本的 Go (可选,但推荐)
|
||||
# 1. Install Go from go.mod
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# 2. 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: brew install pnpm
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
# 3. 运行你的 Makefile 编译二进制文件
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack install
|
||||
|
||||
# 3. Build the application bundle
|
||||
- name: Build with Make
|
||||
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||
|
||||
# 4. 签名
|
||||
# 4. Apply ad-hoc signing
|
||||
- name: Ad-hoc Sign
|
||||
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||
|
||||
# 5. 安装打包工具
|
||||
# 5. Install the DMG packaging tool
|
||||
- name: Install create-dmg
|
||||
run: brew install create-dmg
|
||||
|
||||
# 6. 执行打包命令
|
||||
# 6. Create the DMG
|
||||
- name: Create DMG
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
|
@ -54,7 +60,7 @@ jobs:
|
|||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||
"build/PicoClaw Launcher.app"
|
||||
|
||||
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||
# 7. Upload the DMG as a GitHub artifact
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
|
|
|||
11
.github/workflows/nightly.yml
vendored
11
.github/workflows/nightly.yml
vendored
|
|
@ -51,9 +51,11 @@ jobs:
|
|||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
run: corepack enable && corepack install
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
|
@ -75,6 +77,9 @@ jobs:
|
|||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Create local tag for GoReleaser
|
||||
run: git tag "${{ steps.version.outputs.version }}"
|
||||
|
||||
|
|
@ -90,6 +95,7 @@ jobs:
|
|||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
NIGHTLY_BUILD: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
|
|
@ -123,7 +129,7 @@ jobs:
|
|||
|
||||
# Collect release artifacts from goreleaser dist/
|
||||
ASSETS=()
|
||||
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
||||
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
|
||||
[ -f "$f" ] && ASSETS+=("$f")
|
||||
done
|
||||
|
||||
|
|
@ -135,4 +141,3 @@ jobs:
|
|||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -69,9 +69,11 @@ jobs:
|
|||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
run: corepack enable && corepack install
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
|
@ -93,6 +95,9 @@ jobs:
|
|||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
|
|
@ -104,6 +109,7 @@ jobs:
|
|||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ git:
|
|||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
- sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
|
||||
- sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
|
||||
- sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
|
|
@ -27,7 +26,7 @@ builds:
|
|||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
@ -67,6 +66,10 @@ builds:
|
|||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
@ -106,6 +109,10 @@ builds:
|
|||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
@ -245,6 +252,8 @@ changelog:
|
|||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
extra_files:
|
||||
- glob: ./build/picoclaw-android-universal.zip
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
|
|||
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
||||
- Keep commits focused. One logical change per commit is preferred.
|
||||
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
||||
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
|
||||
- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
|
||||
### Keeping Up to Date
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ git checkout -b 你的功能分支名
|
|||
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
||||
- 保持 commit 专注,每个 commit 只做一件事。
|
||||
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
||||
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
|
||||
- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写
|
||||
|
||||
### 保持与上游同步
|
||||
|
||||
|
|
|
|||
39
Makefile
39
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: all build install uninstall clean help test
|
||||
.PHONY: all build install uninstall clean help test build-all
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=picoclaw
|
||||
|
|
@ -205,11 +205,44 @@ build-linux-mipsle: generate
|
|||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||
|
||||
## build-android-arm64: Build core for Android ARM64
|
||||
build-android-arm64: generate
|
||||
@echo "Building for android/arm64..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
|
||||
|
||||
## build-launcher-android-arm64: Build launcher for Android ARM64
|
||||
build-launcher-android-arm64:
|
||||
@echo "Building picoclaw-launcher for android/arm64..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(MAKE) -C web build-android-arm64 \
|
||||
OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
|
||||
GO='$(GO)' \
|
||||
LDFLAGS='$(LDFLAGS)'
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
|
||||
|
||||
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
|
||||
build-android-bundle: generate
|
||||
@echo "Building core for all Android architectures..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
||||
@echo "Building launcher for Android arm64..."
|
||||
@$(MAKE) build-launcher-android-arm64
|
||||
@echo "Staging JNI libs..."
|
||||
@rm -rf $(BUILD_DIR)/android-staging
|
||||
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
|
||||
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
|
||||
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
|
||||
@rm -rf $(BUILD_DIR)/android-staging
|
||||
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
|
||||
|
||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||
build-pi-zero: build-linux-arm build-linux-arm64
|
||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||
|
||||
## build-all: Build picoclaw for all platforms
|
||||
## build-all: Build the picoclaw core binary for all Makefile-managed platforms
|
||||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
|
|
@ -226,7 +259,7 @@ build-all: generate
|
|||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
@echo "Core builds complete"
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
install: build
|
||||
|
|
|
|||
12
README.fr.md
12
README.fr.md
|
|
@ -167,19 +167,27 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page
|
|||
|
||||
### Compiler depuis les sources (pour le développement)
|
||||
|
||||
Prérequis :
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ avec Corepack activé pour les builds Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Installer le gestionnaire de paquets frontend déclaré par le dépôt
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Compiler le binaire principal
|
||||
make build
|
||||
|
||||
# Compiler le Web UI Launcher (requis pour le mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compiler pour plusieurs plateformes
|
||||
# Compiler les binaires core pour toutes les plateformes gérées par le Makefile
|
||||
make build-all
|
||||
|
||||
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
|
||||
|
|
@ -620,5 +628,3 @@ Discord : <https://discord.gg/V4sAZ9XWpN>
|
|||
WeChat :
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
10
README.id.md
10
README.id.md
|
|
@ -164,19 +164,27 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi
|
|||
|
||||
### Build dari source (untuk pengembangan)
|
||||
|
||||
Prasyarat:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ dengan Corepack aktif untuk build Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Instal package manager frontend yang dideklarasikan repo
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Build binary inti
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Build untuk berbagai platform
|
||||
# Build binary inti untuk semua platform yang dikelola Makefile
|
||||
make build-all
|
||||
|
||||
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
10
README.it.md
10
README.it.md
|
|
@ -164,19 +164,27 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi
|
|||
|
||||
### Compila dai sorgenti (per lo sviluppo)
|
||||
|
||||
Prerequisiti:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ con Corepack abilitato per le build Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Installa il package manager frontend dichiarato dal repository
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Compila il binario core
|
||||
make build
|
||||
|
||||
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compila per più piattaforme
|
||||
# Compila i binari core per tutte le piattaforme gestite dal Makefile
|
||||
make build-all
|
||||
|
||||
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
10
README.ja.md
10
README.ja.md
|
|
@ -164,19 +164,27 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
|||
|
||||
### ソースからビルド(開発用)
|
||||
|
||||
前提条件:
|
||||
|
||||
- Go 1.25+
|
||||
- Web UI / launcher のビルドには Corepack を有効にした Node.js 22+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# リポジトリで宣言されたフロントエンド用パッケージマネージャーをインストール
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# コアバイナリをビルド
|
||||
make build
|
||||
|
||||
# Web UI Launcher をビルド(WebUI モードに必要)
|
||||
make build-launcher
|
||||
|
||||
# 複数プラットフォーム向けビルド
|
||||
# Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド
|
||||
make build-all
|
||||
|
||||
# Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
10
README.ko.md
10
README.ko.md
|
|
@ -164,19 +164,27 @@ PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
|
|||
|
||||
### 소스에서 빌드(개발용)
|
||||
|
||||
필수 사항:
|
||||
|
||||
- Go 1.25+
|
||||
- Web UI / launcher 빌드를 위한 Corepack 활성화된 Node.js 22+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 저장소에 선언된 프런트엔드 패키지 매니저 설치
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# 코어 바이너리 빌드
|
||||
make build
|
||||
|
||||
# WebUI 런처 빌드 (WebUI 모드에 필요)
|
||||
make build-launcher
|
||||
|
||||
# 여러 플랫폼용 빌드
|
||||
# Makefile이 관리하는 모든 플랫폼용 코어 바이너리 빌드
|
||||
make build-all
|
||||
|
||||
# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64)
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -164,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
|
|||
|
||||
### Build from source (for development)
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ with Corepack enabled for Web UI / launcher builds
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build core binary
|
||||
# Install frontend package manager declared by the repo
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Build the core binary for the current platform
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (required for WebUI mode)
|
||||
# Build the Web UI Launcher (required for WebUI mode)
|
||||
make build-launcher
|
||||
|
||||
# Build for multiple platforms
|
||||
# Build core binaries for all Makefile-managed platforms
|
||||
make build-all
|
||||
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
# Build for Raspberry Pi Zero 2 W
|
||||
# 32-bit: make build-linux-arm
|
||||
# 64-bit: make build-linux-arm64
|
||||
make build-pi-zero
|
||||
|
||||
# Build and install
|
||||
|
|
|
|||
10
README.my.md
10
README.my.md
|
|
@ -165,18 +165,26 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git
|
|||
|
||||
### Bina dari sumber (untuk pembangunan)
|
||||
|
||||
Prasyarat:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ dengan Corepack diaktifkan untuk binaan Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Pasang pengurus pakej frontend yang diisytiharkan oleh repositori
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Bina binari teras
|
||||
make build
|
||||
|
||||
# Bina Pelancar Web UI (diperlukan untuk mod WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Bina untuk pelbagai platform
|
||||
# Bina binari teras untuk semua platform yang diuruskan oleh Makefile
|
||||
make build-all
|
||||
|
||||
# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
|
|
@ -164,19 +164,27 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel
|
|||
|
||||
### Compilar a partir do código-fonte (para desenvolvimento)
|
||||
|
||||
Pré-requisitos:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ com Corepack habilitado para builds do Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Instalar o gerenciador de pacotes de frontend declarado pelo repositório
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Compilar o binário principal
|
||||
make build
|
||||
|
||||
# Compilar o Web UI Launcher (necessário para o modo WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compilar para múltiplas plataformas
|
||||
# Compilar os binários core para todas as plataformas gerenciadas pelo Makefile
|
||||
make build-all
|
||||
|
||||
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
14
README.vi.md
14
README.vi.md
|
|
@ -164,19 +164,27 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases
|
|||
|
||||
### Xây dựng từ mã nguồn (để phát triển)
|
||||
|
||||
Yêu cầu:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ với Corepack được bật cho các bản build Web UI / launcher
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build core binary
|
||||
# Cài đặt trình quản lý gói frontend được khai báo bởi repo
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# Build binary lõi
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (required for WebUI mode)
|
||||
# Build Web UI Launcher (cần cho chế độ WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Build for multiple platforms
|
||||
# Build các binary lõi cho mọi nền tảng do Makefile quản lý
|
||||
make build-all
|
||||
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
|
|
|
|||
12
README.zh.md
12
README.zh.md
|
|
@ -164,19 +164,27 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
|
|||
|
||||
### 从源码构建(开发用)
|
||||
|
||||
前置要求:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+,并启用 Corepack(用于 Web UI / launcher 构建)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 安装仓库声明的前端包管理器
|
||||
(cd web/frontend && corepack install)
|
||||
|
||||
# 构建核心二进制文件
|
||||
make build
|
||||
|
||||
# 构建 Web UI Launcher(WebUI 模式必需)
|
||||
make build-launcher
|
||||
|
||||
# 为多平台构建
|
||||
# 为 Makefile 管理的所有平台构建核心二进制文件
|
||||
make build-all
|
||||
|
||||
# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64)
|
||||
|
|
@ -618,5 +626,3 @@ WeChat:
|
|||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
147
cmd/picoclaw/internal/cliui/cliui.go
Normal file
147
cmd/picoclaw/internal/cliui/cliui.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Package cliui renders human-oriented CLI output: bordered panels and columns
|
||||
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
|
||||
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
|
||||
// stdout falls back to plain line-oriented output.
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Minimum terminal width (columns) for bordered / structured layout.
|
||||
// Below this, plain line-oriented output is used so boxes do not wrap badly.
|
||||
const minWidthFancy = 88
|
||||
|
||||
// Minimum width to lay out some views in two columns (e.g. status providers).
|
||||
const minWidthColumns = 104
|
||||
|
||||
var initMu sync.Mutex
|
||||
|
||||
// Init configures lipgloss for this process. When disableAnsiColors is true
|
||||
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
|
||||
// borders still render when UseFancyLayout() is true.
|
||||
func Init(disableAnsiColors bool) {
|
||||
initMu.Lock()
|
||||
defer initMu.Unlock()
|
||||
if disableAnsiColors {
|
||||
lipgloss.SetColorProfile(termenv.Ascii)
|
||||
return
|
||||
}
|
||||
lipgloss.SetColorProfile(termenv.EnvColorProfile())
|
||||
}
|
||||
|
||||
// StdoutWidth returns the terminal width or a sane default if unknown.
|
||||
func StdoutWidth() int {
|
||||
w, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || w < 20 {
|
||||
return 80
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// UseFancyLayout is true when styled boxes/columns should be used.
|
||||
func UseFancyLayout() bool {
|
||||
if !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return false
|
||||
}
|
||||
return StdoutWidth() >= minWidthFancy
|
||||
}
|
||||
|
||||
// UseColumnLayout is true when a second content column is viable.
|
||||
func UseColumnLayout() bool {
|
||||
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
|
||||
}
|
||||
|
||||
// InnerWidth is the target content width inside borders/margins.
|
||||
func InnerWidth() int {
|
||||
w := StdoutWidth()
|
||||
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
|
||||
const borderBudget = 8
|
||||
if w > borderBudget+48 {
|
||||
return w - borderBudget
|
||||
}
|
||||
return 48
|
||||
}
|
||||
|
||||
// StderrWidth returns stderr terminal width or a sane default.
|
||||
func StderrWidth() int {
|
||||
w, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||
if err != nil || w < 20 {
|
||||
return 80
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
|
||||
func UseFancyStderr() bool {
|
||||
if !term.IsTerminal(int(os.Stderr.Fd())) {
|
||||
return false
|
||||
}
|
||||
return StderrWidth() >= minWidthFancy
|
||||
}
|
||||
|
||||
// InnerStderrWidth mirrors InnerWidth but for stderr.
|
||||
func InnerStderrWidth() int {
|
||||
w := StderrWidth()
|
||||
const borderBudget = 8
|
||||
if w > borderBudget+48 {
|
||||
return w - borderBudget
|
||||
}
|
||||
return 48
|
||||
}
|
||||
|
||||
var (
|
||||
accentBlue = lipgloss.Color("#3E5DB9")
|
||||
accentRed = lipgloss.Color("#D54646")
|
||||
colorMuted = lipgloss.Color("#6B6B6B")
|
||||
colorOK = lipgloss.Color("#2E7D32")
|
||||
)
|
||||
|
||||
func borderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(accentBlue).
|
||||
Padding(0, 1)
|
||||
}
|
||||
|
||||
func titleBarStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(accentRed).
|
||||
Bold(true)
|
||||
}
|
||||
|
||||
func mutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(colorMuted)
|
||||
}
|
||||
|
||||
func bodyStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
|
||||
func kvKeyStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||
}
|
||||
|
||||
func kvValStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
|
||||
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
|
||||
func helpIntroStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||
}
|
||||
|
||||
// helpIdentStyle is the left column for commands and flags (blue identifiers).
|
||||
func helpIdentStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||
}
|
||||
|
||||
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
|
||||
func helpPlaceholderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
|
||||
}
|
||||
180
cmd/picoclaw/internal/cliui/cliui_test.go
Normal file
180
cmd/picoclaw/internal/cliui/cliui_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Disable ANSI colors in tests so output is predictable plain text.
|
||||
Init(true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// showErrHint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShowErrHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want bool
|
||||
}{
|
||||
// Cobra flag errors — should show hint
|
||||
{"unknown flag: --foo", true},
|
||||
{"unknown shorthand flag: 'f' in -f", true},
|
||||
{"flag needs an argument: --output", true},
|
||||
{"required flag(s) \"model\" not set", true},
|
||||
// Generic invalid-argument errors — should show hint
|
||||
{"invalid argument \"abc\" for --count", true},
|
||||
// required flag errors — should show hint
|
||||
{"required flag(s) \"model\" not set", true},
|
||||
// usage: in message — should show hint
|
||||
{"bad input\nusage: picoclaw ...", true},
|
||||
// Should NOT false-positive on broad words
|
||||
{"connection flagged by remote", false},
|
||||
{"feature flag not set", false},
|
||||
{"invalid API key provided", false},
|
||||
{"authentication required", false},
|
||||
// Unrelated messages — no hint
|
||||
{"something went wrong", false},
|
||||
{"network timeout", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := showErrHint(tc.msg)
|
||||
if got != tc.want {
|
||||
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// styleUsageTokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStyleUsageTokensContainsTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
contains []string // substrings that must appear in plain output
|
||||
}{
|
||||
{
|
||||
"picoclaw agent <message>",
|
||||
[]string{"picoclaw agent", "<message>"},
|
||||
},
|
||||
{
|
||||
"picoclaw [command] [flags]",
|
||||
[]string{"picoclaw", "[command]", "[flags]"},
|
||||
},
|
||||
{
|
||||
"picoclaw",
|
||||
[]string{"picoclaw"},
|
||||
},
|
||||
{
|
||||
"cmd <arg1> [--flag]",
|
||||
[]string{"cmd", "<arg1>", "[--flag]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
out := styleUsageTokens(tc.input)
|
||||
for _, sub := range tc.contains {
|
||||
if !containsStripped(out, sub) {
|
||||
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
|
||||
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
|
||||
// so this is just a plain substring check.
|
||||
func containsStripped(plain, sub string) bool {
|
||||
return len(plain) >= len(sub) && findSubstring(plain, sub)
|
||||
}
|
||||
|
||||
func findSubstring(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// collectFlagRows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCollectFlagRows_Empty(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
rows := collectFlagRows(fs)
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlagRows_BasicFlags(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("output", "", "output file path")
|
||||
fs.Bool("verbose", false, "enable verbose mode")
|
||||
fs.Int("count", 1, "number of items")
|
||||
|
||||
rows := collectFlagRows(fs)
|
||||
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||
}
|
||||
|
||||
// Rows must be sorted alphabetically by flag name.
|
||||
names := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
names = append(names, r[0])
|
||||
}
|
||||
if names[0] > names[1] || names[1] > names[2] {
|
||||
t.Errorf("rows not sorted: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlagRows_Shorthand(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.StringP("model", "m", "", "model name")
|
||||
|
||||
rows := collectFlagRows(fs)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
left := rows[0][0]
|
||||
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
|
||||
t.Errorf("expected shorthand and long form in %q", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("visible", "", "this shows up")
|
||||
hidden := fs.String("hidden", "", "this should not show up")
|
||||
_ = hidden
|
||||
_ = fs.MarkHidden("hidden")
|
||||
|
||||
rows := collectFlagRows(fs)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
|
||||
}
|
||||
if !findSubstring(rows[0][0], "visible") {
|
||||
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("format", "json", "output format: json or text")
|
||||
|
||||
rows := collectFlagRows(fs)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0][1] != "output format: json or text" {
|
||||
t.Errorf("expected usage in right column, got %q", rows[0][1])
|
||||
}
|
||||
}
|
||||
298
cmd/picoclaw/internal/cliui/help_cmd.go
Normal file
298
cmd/picoclaw/internal/cliui/help_cmd.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
|
||||
// UseFancyLayout(); otherwise plain Cobra-style text.
|
||||
func RenderCommandHelp(c *cobra.Command) string {
|
||||
if !UseFancyLayout() {
|
||||
return plainCommandHelp(c)
|
||||
}
|
||||
syncFlags(c)
|
||||
|
||||
var b strings.Builder
|
||||
head, sub := helpIntro(c)
|
||||
if head != "" {
|
||||
b.WriteString(helpIntroStyle().Render(head))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if sub != "" {
|
||||
b.WriteString(mutedStyle().Render(sub))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if head != "" || sub != "" {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
inner := InnerWidth()
|
||||
contentW := inner - 6
|
||||
if contentW < 36 {
|
||||
contentW = 36
|
||||
}
|
||||
|
||||
// Usage
|
||||
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
||||
b.WriteString(sectionPanel("Usage", usageBody, inner))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Examples
|
||||
if ex := strings.TrimSpace(c.Example); ex != "" {
|
||||
exBody := bodyStyle().Width(contentW).Render(ex)
|
||||
b.WriteString(sectionPanel("Examples", exBody, inner))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Subcommands
|
||||
subs := visibleSubcommands(c)
|
||||
if len(subs) > 0 {
|
||||
rows := make([][2]string, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
left := sub.Name()
|
||||
if a := sub.Aliases; len(a) > 0 {
|
||||
left += " (" + strings.Join(a, ", ") + ")"
|
||||
}
|
||||
rows = append(rows, [2]string{left, sub.Short})
|
||||
}
|
||||
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Local options
|
||||
local := c.LocalFlags()
|
||||
opts := collectFlagRows(local)
|
||||
if len(opts) > 0 {
|
||||
title := "Options"
|
||||
if !c.HasParent() {
|
||||
title = "Flags"
|
||||
}
|
||||
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Global (inherited) options
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
inh := collectFlagRows(c.InheritedFlags())
|
||||
if len(inh) > 0 {
|
||||
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
|
||||
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
|
||||
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
|
||||
if c == nil || outerW < 40 {
|
||||
return ""
|
||||
}
|
||||
syncFlags(c)
|
||||
contentW := outerW - 6
|
||||
if contentW < 36 {
|
||||
contentW = 36
|
||||
}
|
||||
var b strings.Builder
|
||||
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
||||
b.WriteString(sectionPanel("Usage", usageBody, outerW))
|
||||
b.WriteString("\n")
|
||||
if len(c.Aliases) > 0 {
|
||||
al := "Aliases: " + strings.Join(c.Aliases, ", ")
|
||||
alBody := mutedStyle().MaxWidth(contentW).Render(al)
|
||||
b.WriteString(sectionPanel("Aliases", alBody, outerW))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
opts := collectFlagRows(c.LocalFlags())
|
||||
if len(opts) > 0 {
|
||||
title := "Options"
|
||||
if !c.HasParent() {
|
||||
title = "Flags"
|
||||
}
|
||||
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
inh := collectFlagRows(c.InheritedFlags())
|
||||
if len(inh) > 0 {
|
||||
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func syncFlags(c *cobra.Command) {
|
||||
_ = c.LocalFlags()
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
_ = c.InheritedFlags()
|
||||
}
|
||||
}
|
||||
|
||||
func plainCommandHelp(c *cobra.Command) string {
|
||||
desc := c.Long
|
||||
if desc == "" {
|
||||
desc = c.Short
|
||||
}
|
||||
desc = strings.TrimRight(desc, " \t\n\r")
|
||||
var b strings.Builder
|
||||
if desc != "" {
|
||||
fmt.Fprintln(&b, desc)
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
if c.Runnable() || c.HasSubCommands() {
|
||||
b.WriteString(c.UsageString())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func helpIntro(c *cobra.Command) (head, sub string) {
|
||||
head = strings.TrimSpace(c.Short)
|
||||
long := strings.TrimSpace(c.Long)
|
||||
if long == "" || long == head {
|
||||
return head, ""
|
||||
}
|
||||
lines := strings.Split(long, "\n")
|
||||
var rest []string
|
||||
for i, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
if i == 0 && ln == head {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, ln)
|
||||
}
|
||||
sub = strings.Join(rest, "\n")
|
||||
return head, sub
|
||||
}
|
||||
|
||||
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
|
||||
var out []*cobra.Command
|
||||
for _, sub := range c.Commands() {
|
||||
if sub.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, sub)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out
|
||||
}
|
||||
|
||||
func sectionPanel(title, body string, width int) string {
|
||||
head := titleBarStyle().Render(title) + "\n\n"
|
||||
return borderStyle().Width(width).Render(head + body)
|
||||
}
|
||||
|
||||
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
|
||||
func styleUsageTokens(s string) string {
|
||||
var b strings.Builder
|
||||
for len(s) > 0 {
|
||||
ia := strings.Index(s, "<")
|
||||
ib := strings.Index(s, "[")
|
||||
next, kind := -1, 0 // 1 = angle, 2 = bracket
|
||||
switch {
|
||||
case ia >= 0 && (ib < 0 || ia < ib):
|
||||
next, kind = ia, 1
|
||||
case ib >= 0:
|
||||
next, kind = ib, 2
|
||||
}
|
||||
if next < 0 {
|
||||
b.WriteString(helpIdentStyle().Render(s))
|
||||
break
|
||||
}
|
||||
if next > 0 {
|
||||
b.WriteString(helpIdentStyle().Render(s[:next]))
|
||||
}
|
||||
s = s[next:]
|
||||
if kind == 1 {
|
||||
j := strings.Index(s, ">")
|
||||
if j < 0 {
|
||||
b.WriteString(helpIdentStyle().Render(s))
|
||||
break
|
||||
}
|
||||
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
||||
s = s[j+1:]
|
||||
continue
|
||||
}
|
||||
j := strings.Index(s, "]")
|
||||
if j < 0 {
|
||||
b.WriteString(helpIdentStyle().Render(s))
|
||||
break
|
||||
}
|
||||
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
||||
s = s[j+1:]
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func collectFlagRows(fs *flag.FlagSet) [][2]string {
|
||||
var names []string
|
||||
seen := map[string][2]string{}
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
left := formatFlagLeft(f)
|
||||
right := f.Usage
|
||||
if f.Deprecated != "" {
|
||||
right += " (deprecated: " + f.Deprecated + ")"
|
||||
}
|
||||
names = append(names, f.Name)
|
||||
seen[f.Name] = [2]string{left, right}
|
||||
})
|
||||
sort.Strings(names)
|
||||
rows := make([][2]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
rows = append(rows, seen[n])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func formatFlagLeft(f *flag.Flag) string {
|
||||
if len(f.Shorthand) > 0 {
|
||||
return "-" + f.Shorthand + ", --" + f.Name
|
||||
}
|
||||
return "--" + f.Name
|
||||
}
|
||||
|
||||
func renderTwoColPairs(rows [][2]string, contentW int) string {
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
leftW := 0
|
||||
for _, r := range rows {
|
||||
if w := lipgloss.Width(r[0]); w > leftW {
|
||||
leftW = w
|
||||
}
|
||||
}
|
||||
const minLeft, maxLeft = 16, 34
|
||||
if leftW < minLeft {
|
||||
leftW = minLeft
|
||||
}
|
||||
if leftW > maxLeft {
|
||||
leftW = maxLeft
|
||||
}
|
||||
gap := " "
|
||||
rightW := contentW - leftW - lipgloss.Width(gap)
|
||||
if rightW < 24 {
|
||||
rightW = 24
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range rows {
|
||||
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
|
||||
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
75
cmd/picoclaw/internal/cliui/help_error.go
Normal file
75
cmd/picoclaw/internal/cliui/help_error.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// FormatCLIError formats errors with the same boxed sections as help. When ctx
|
||||
// is the command that was running when the error occurred, Usage / Flags panels
|
||||
// are appended so styling matches picoclaw -h.
|
||||
func FormatCLIError(msg string, ctx *cobra.Command) string {
|
||||
msg = strings.TrimRight(msg, "\n")
|
||||
if !UseFancyStderr() {
|
||||
s := "Error: " + msg + "\n"
|
||||
if ctx != nil && showErrHint(msg) {
|
||||
s += "\n" + plainCommandHelp(ctx)
|
||||
}
|
||||
return s
|
||||
}
|
||||
w := InnerStderrWidth()
|
||||
contentW := w - 6
|
||||
if contentW < 36 {
|
||||
contentW = 36
|
||||
}
|
||||
|
||||
title := titleBarStyle().Render("Error") + "\n\n"
|
||||
|
||||
paras := strings.Split(msg, "\n")
|
||||
var body strings.Builder
|
||||
for i, p := range paras {
|
||||
p = strings.TrimRight(p, " ")
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
st := bodyStyle().Width(contentW)
|
||||
if i > 0 {
|
||||
body.WriteString("\n")
|
||||
}
|
||||
if i == 0 {
|
||||
body.WriteString(st.Render(p))
|
||||
} else {
|
||||
body.WriteString(mutedStyle().Width(contentW).Render(p))
|
||||
}
|
||||
}
|
||||
|
||||
foot := ""
|
||||
if showErrHint(msg) {
|
||||
if ctx != nil {
|
||||
foot = "\n\n" + mutedStyle().Width(contentW).
|
||||
Render("Full command help: "+ctx.CommandPath()+" --help")
|
||||
} else {
|
||||
foot = "\n\n" + mutedStyle().Width(contentW).
|
||||
Render("Tip: picoclaw --help · picoclaw <command> --help")
|
||||
}
|
||||
}
|
||||
|
||||
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
|
||||
if ctx != nil && showErrHint(msg) {
|
||||
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
|
||||
out += "\n" + ref
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func showErrHint(msg string) bool {
|
||||
m := strings.ToLower(msg)
|
||||
return strings.Contains(m, "unknown flag") ||
|
||||
strings.Contains(m, "unknown shorthand flag") ||
|
||||
strings.Contains(m, "flag needs an argument") ||
|
||||
strings.Contains(m, "invalid argument") ||
|
||||
strings.Contains(m, "required flag") ||
|
||||
strings.Contains(m, "usage:")
|
||||
}
|
||||
110
cmd/picoclaw/internal/cliui/onboard.go
Normal file
110
cmd/picoclaw/internal/cliui/onboard.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
|
||||
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
|
||||
if !UseFancyLayout() {
|
||||
printOnboardPlain(logo, encrypt, configPath)
|
||||
return
|
||||
}
|
||||
printOnboardFancy(logo, encrypt, configPath)
|
||||
}
|
||||
|
||||
func printOnboardPlain(logo string, encrypt bool, configPath string) {
|
||||
fmt.Printf("\n%s picoclaw is ready!\n", logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
if encrypt {
|
||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Add your API key to", configPath)
|
||||
} else {
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println(" Recommended:")
|
||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||
fmt.Println("")
|
||||
fmt.Println(" See README.md for 17+ supported providers.")
|
||||
fmt.Println("")
|
||||
if encrypt {
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
} else {
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
}
|
||||
|
||||
func printOnboardFancy(logo string, encrypt bool, configPath string) {
|
||||
inner := InnerWidth()
|
||||
box := borderStyle().MaxWidth(inner + 8)
|
||||
|
||||
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
|
||||
fmt.Println()
|
||||
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
|
||||
fmt.Println()
|
||||
|
||||
steps := buildOnboardingSteps(encrypt, configPath)
|
||||
rec := recommendedBlock()
|
||||
chat := chatStep(encrypt)
|
||||
|
||||
if UseColumnLayout() {
|
||||
leftW := min(inner/2-2, 52)
|
||||
rightW := inner - leftW - 4
|
||||
if rightW < 36 {
|
||||
rightW = 36
|
||||
}
|
||||
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
|
||||
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
|
||||
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
|
||||
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
|
||||
gap := strings.Repeat(" ", 2)
|
||||
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
|
||||
fmt.Println()
|
||||
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
|
||||
fmt.Println(full)
|
||||
return
|
||||
}
|
||||
|
||||
// Same order as plain output: numbered steps → recommended → chat line.
|
||||
next := titleBarStyle().Render("Next steps") + "\n\n" +
|
||||
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
|
||||
fmt.Println(borderStyle().Width(inner).Render(next))
|
||||
}
|
||||
|
||||
func buildOnboardingSteps(encrypt bool, configPath string) string {
|
||||
var b strings.Builder
|
||||
if encrypt {
|
||||
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
|
||||
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
|
||||
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
|
||||
b.WriteString("2. Add your API key to\n ")
|
||||
b.WriteString(configPath)
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("1. Add your API key to\n ")
|
||||
b.WriteString(configPath)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func recommendedBlock() string {
|
||||
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
|
||||
"• Ollama: https://ollama.com\n (local, free)\n\n" +
|
||||
"See README.md for 17+ supported providers."
|
||||
}
|
||||
|
||||
func chatStep(encrypt bool) string {
|
||||
if encrypt {
|
||||
return "3. Chat:\n picoclaw agent -m \"Hello!\""
|
||||
}
|
||||
return "2. Chat:\n picoclaw agent -m \"Hello!\""
|
||||
}
|
||||
168
cmd/picoclaw/internal/cliui/status.go
Normal file
168
cmd/picoclaw/internal/cliui/status.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ProviderRow holds one provider's display name and status value.
|
||||
type ProviderRow struct {
|
||||
Name string
|
||||
Val string
|
||||
}
|
||||
|
||||
// StatusReport is a structured status view for PrintStatus.
|
||||
type StatusReport struct {
|
||||
Logo string
|
||||
Version string
|
||||
Build string
|
||||
ConfigPath string
|
||||
ConfigOK bool
|
||||
WorkspacePath string
|
||||
WorkspaceOK bool
|
||||
Model string
|
||||
Providers []ProviderRow
|
||||
OAuthLines []string // each full line "provider (method): state"
|
||||
}
|
||||
|
||||
// PrintStatus renders picoclaw status (plain or fancy).
|
||||
func PrintStatus(r StatusReport) {
|
||||
if !UseFancyLayout() {
|
||||
printStatusPlain(r)
|
||||
return
|
||||
}
|
||||
printStatusFancy(r)
|
||||
}
|
||||
|
||||
func printStatusPlain(r StatusReport) {
|
||||
fmt.Printf("%s picoclaw Status\n", r.Logo)
|
||||
fmt.Printf("Version: %s\n", r.Version)
|
||||
if r.Build != "" {
|
||||
fmt.Printf("Build: %s\n", r.Build)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
printPathLine("Config", r.ConfigPath, r.ConfigOK)
|
||||
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
|
||||
|
||||
if r.ConfigOK {
|
||||
fmt.Printf("Model: %s\n", r.Model)
|
||||
for _, p := range r.Providers {
|
||||
fmt.Printf("%s: %s\n", p.Name, p.Val)
|
||||
}
|
||||
if len(r.OAuthLines) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
for _, line := range r.OAuthLines {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printPathLine(label, path string, ok bool) {
|
||||
mark := "✗"
|
||||
if ok {
|
||||
mark = "✓"
|
||||
}
|
||||
fmt.Println(label+":", path, mark)
|
||||
}
|
||||
|
||||
func printStatusFancy(r StatusReport) {
|
||||
inner := InnerWidth()
|
||||
topBox := borderStyle().Width(inner)
|
||||
|
||||
var head strings.Builder
|
||||
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
|
||||
head.WriteString("\n\n")
|
||||
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
|
||||
if r.Build != "" {
|
||||
head.WriteString("\n")
|
||||
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
|
||||
}
|
||||
fmt.Println(topBox.Render(head.String()))
|
||||
fmt.Println()
|
||||
|
||||
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
|
||||
leftW := (inner - 2) / 2
|
||||
rightW := inner - leftW - 2
|
||||
pathsNarrow := pathStatusPanel(r, leftW)
|
||||
prov := providerTablePanel(r, rightW)
|
||||
gap := strings.Repeat(" ", 2)
|
||||
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
|
||||
} else {
|
||||
fmt.Println(pathStatusPanel(r, inner))
|
||||
if len(r.Providers) > 0 && r.ConfigOK {
|
||||
fmt.Println(providerTablePanel(r, inner))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.OAuthLines) > 0 && r.ConfigOK {
|
||||
var ob strings.Builder
|
||||
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
|
||||
for _, line := range r.OAuthLines {
|
||||
ob.WriteString(" • " + line + "\n")
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func pathStatusPanel(r StatusReport, inner int) string {
|
||||
cfgMark := statusMark(r.ConfigOK)
|
||||
wsMark := statusMark(r.WorkspaceOK)
|
||||
var b strings.Builder
|
||||
b.WriteString(kvKeyStyle().Render("Config") + "\n")
|
||||
b.WriteString(mutedStyle().Render(r.ConfigPath))
|
||||
b.WriteString(" " + cfgMark + "\n\n")
|
||||
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
|
||||
b.WriteString(mutedStyle().Render(r.WorkspacePath))
|
||||
b.WriteString(" " + wsMark + "\n")
|
||||
if r.ConfigOK {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
|
||||
}
|
||||
return borderStyle().Width(inner).Render(b.String())
|
||||
}
|
||||
|
||||
func statusMark(ok bool) string {
|
||||
if ok {
|
||||
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
|
||||
}
|
||||
|
||||
func providerTablePanel(r StatusReport, colW int) string {
|
||||
if len(r.Providers) == 0 {
|
||||
return ""
|
||||
}
|
||||
keyW := min(22, colW/3)
|
||||
if keyW < 14 {
|
||||
keyW = 14
|
||||
}
|
||||
valW := colW - keyW - 3
|
||||
if valW < 12 {
|
||||
valW = 12
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
|
||||
for _, p := range r.Providers {
|
||||
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
|
||||
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
|
||||
}
|
||||
|
||||
func styleProviderVal(s string) lipgloss.Style {
|
||||
if s == "✓" || strings.HasPrefix(s, "✓ ") {
|
||||
return lipgloss.NewStyle().Foreground(colorOK)
|
||||
}
|
||||
if s == "not set" {
|
||||
return mutedStyle()
|
||||
}
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
61
cmd/picoclaw/internal/cliui/version.go
Normal file
61
cmd/picoclaw/internal/cliui/version.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// PrintVersion prints version, optional build info, and Go toolchain line.
|
||||
func PrintVersion(logo, versionLine string, build, goVer string) {
|
||||
if !UseFancyLayout() {
|
||||
fmt.Printf("%s %s\n", logo, versionLine)
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inner := InnerWidth()
|
||||
box := borderStyle().Width(inner)
|
||||
|
||||
if UseColumnLayout() {
|
||||
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
|
||||
rightW := inner - 16
|
||||
rightStyle := kvValStyle().Width(rightW)
|
||||
|
||||
rows := [][]string{
|
||||
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
|
||||
}
|
||||
if build != "" {
|
||||
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
|
||||
}
|
||||
if goVer != "" {
|
||||
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
|
||||
}
|
||||
var body strings.Builder
|
||||
for _, r := range rows {
|
||||
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
|
||||
body.WriteString("\n")
|
||||
}
|
||||
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
|
||||
fmt.Println(box.Render(header + body.String()))
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
|
||||
if build != "" {
|
||||
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
|
||||
}
|
||||
if goVer != "" {
|
||||
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
|
||||
}
|
||||
fmt.Println(box.Render(strings.Join(lines, "\n")))
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"golang.org/x/term"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
|
@ -79,29 +80,7 @@ func onboard(encrypt bool) {
|
|||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
if encrypt {
|
||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Add your API key to", configPath)
|
||||
} else {
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println(" Recommended:")
|
||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||
fmt.Println("")
|
||||
fmt.Println(" See README.md for 17+ supported providers.")
|
||||
fmt.Println("")
|
||||
if encrypt {
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
} else {
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
|
||||
}
|
||||
|
||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package status
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
|
@ -17,43 +19,125 @@ func statusCmd() {
|
|||
}
|
||||
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||
build, _ := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf("Build: %s\n", build)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Println("Config:", configPath, "✓")
|
||||
} else {
|
||||
fmt.Println("Config:", configPath, "✗")
|
||||
}
|
||||
_, configStatErr := os.Stat(configPath)
|
||||
configOK := configStatErr == nil
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
if _, err := os.Stat(workspace); err == nil {
|
||||
fmt.Println("Workspace:", workspace, "✓")
|
||||
} else {
|
||||
fmt.Println("Workspace:", workspace, "✗")
|
||||
_, wsErr := os.Stat(workspace)
|
||||
wsOK := wsErr == nil
|
||||
|
||||
report := cliui.StatusReport{
|
||||
Logo: internal.Logo,
|
||||
Version: config.FormatVersion(),
|
||||
Build: build,
|
||||
ConfigPath: configPath,
|
||||
ConfigOK: configOK,
|
||||
WorkspacePath: workspace,
|
||||
WorkspaceOK: wsOK,
|
||||
Model: cfg.Agents.Defaults.GetModelName(),
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||
if configOK {
|
||||
// PicoClaw moved to a model-centric configuration (model_list). Status should
|
||||
// not depend on a legacy cfg.Providers field (which may not exist under some
|
||||
// build tags). We infer provider availability from model_list entries.
|
||||
hasProtocolKey := func(protocol string) bool {
|
||||
prefix := protocol + "/"
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findLocalModelBase := func(modelName string) (string, bool) {
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.ModelName == modelName && m.APIBase != "" {
|
||||
return m.APIBase, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
findProtocolBase := func(protocol string) (string, bool) {
|
||||
prefix := protocol + "/"
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
|
||||
return m.APIBase, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
hasOpenRouter := hasProtocolKey("openrouter")
|
||||
hasAnthropic := hasProtocolKey("anthropic")
|
||||
hasOpenAI := hasProtocolKey("openai")
|
||||
hasGemini := hasProtocolKey("gemini")
|
||||
hasZhipu := hasProtocolKey("zhipu")
|
||||
hasQwen := hasProtocolKey("qwen")
|
||||
hasGroq := hasProtocolKey("groq")
|
||||
hasMoonshot := hasProtocolKey("moonshot")
|
||||
hasDeepSeek := hasProtocolKey("deepseek")
|
||||
hasVolcEngine := hasProtocolKey("volcengine")
|
||||
hasNvidia := hasProtocolKey("nvidia")
|
||||
|
||||
// Local endpoints: allow both the special reserved name and protocol-based entries.
|
||||
vllmBase, hasVLLM := findLocalModelBase("local-model")
|
||||
if !hasVLLM {
|
||||
vllmBase, hasVLLM = findProtocolBase("vllm")
|
||||
}
|
||||
ollamaBase, hasOllama := findProtocolBase("ollama")
|
||||
|
||||
val := func(enabled bool, extra ...string) string {
|
||||
if enabled {
|
||||
if len(extra) > 0 && extra[0] != "" {
|
||||
return "✓ " + extra[0]
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
return "not set"
|
||||
}
|
||||
|
||||
report.Providers = []cliui.ProviderRow{
|
||||
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
|
||||
{Name: "Anthropic API", Val: val(hasAnthropic)},
|
||||
{Name: "OpenAI API", Val: val(hasOpenAI)},
|
||||
{Name: "Gemini API", Val: val(hasGemini)},
|
||||
{Name: "Zhipu API", Val: val(hasZhipu)},
|
||||
{Name: "Qwen API", Val: val(hasQwen)},
|
||||
{Name: "Groq API", Val: val(hasGroq)},
|
||||
{Name: "Moonshot API", Val: val(hasMoonshot)},
|
||||
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
|
||||
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
|
||||
{Name: "Nvidia API", Val: val(hasNvidia)},
|
||||
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
|
||||
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "authenticated"
|
||||
st := "authenticated"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
st = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
st = "needs refresh"
|
||||
}
|
||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||
report.OAuthLines = append(report.OAuthLines,
|
||||
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliui.PrintStatus(report)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||
build, goVer := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
|
|
@ -28,15 +29,57 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/updater"
|
||||
)
|
||||
|
||||
var rootNoColor bool
|
||||
|
||||
func syncCliUIColor(root *cobra.Command) {
|
||||
no, _ := root.PersistentFlags().GetBool("no-color")
|
||||
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
||||
}
|
||||
|
||||
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
|
||||
func earlyColorDisabled() bool {
|
||||
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
|
||||
return true
|
||||
}
|
||||
for i := 1; i < len(os.Args); i++ {
|
||||
arg := os.Args[i]
|
||||
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewPicoclawCommand() *cobra.Command {
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
||||
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
|
||||
|
||||
Version: %s`, internal.Logo, config.FormatVersion())
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Example: "picoclaw version",
|
||||
Long: long,
|
||||
Example: `picoclaw version
|
||||
picoclaw onboard
|
||||
picoclaw --no-color status`,
|
||||
SilenceErrors: true,
|
||||
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
|
||||
// renders matching panels on stderr instead.
|
||||
SilenceUsage: true,
|
||||
PersistentPreRun: func(c *cobra.Command, _ []string) {
|
||||
syncCliUIColor(c.Root())
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
|
||||
"Disable colors (boxed layout unchanged)")
|
||||
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
|
||||
syncCliUIColor(c.Root())
|
||||
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
|
||||
})
|
||||
|
||||
cmd.AddCommand(
|
||||
onboard.NewOnboardCommand(),
|
||||
agent.NewAgentCommand(),
|
||||
|
|
@ -65,17 +108,31 @@ const (
|
|||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"\033[0m\r\n"
|
||||
plainBanner = "\r\n" +
|
||||
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%s", banner)
|
||||
cliui.Init(earlyColorDisabled())
|
||||
|
||||
tz_env := os.Getenv("TZ")
|
||||
if tz_env != "" {
|
||||
fmt.Println("TZ environment:", tz_env)
|
||||
zoneinfo_env := os.Getenv("ZONEINFO")
|
||||
fmt.Println("ZONEINFO environment:", zoneinfo_env)
|
||||
loc, err := time.LoadLocation(tz_env)
|
||||
if earlyColorDisabled() {
|
||||
fmt.Print(plainBanner)
|
||||
} else {
|
||||
fmt.Printf("%s", banner)
|
||||
}
|
||||
|
||||
tzEnv := os.Getenv("TZ")
|
||||
if tzEnv != "" {
|
||||
fmt.Println("TZ environment:", tzEnv)
|
||||
zoneinfoEnv := os.Getenv("ZONEINFO")
|
||||
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
|
||||
loc, err := time.LoadLocation(tzEnv)
|
||||
if err != nil {
|
||||
fmt.Println("Error loading time zone:", err)
|
||||
} else {
|
||||
|
|
@ -85,7 +142,10 @@ func main() {
|
|||
}
|
||||
|
||||
cmd := NewPicoclawCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
last, err := cmd.ExecuteC()
|
||||
if err != nil {
|
||||
syncCliUIColor(cmd)
|
||||
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
||||
longHas := strings.Contains(cmd.Long, config.FormatVersion())
|
||||
|
||||
assert.Equal(t, "picoclaw", cmd.Use)
|
||||
assert.Equal(t, short, cmd.Short)
|
||||
assert.True(t, longHas)
|
||||
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.True(t, cmd.HasAvailableSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.NotNil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
allowedCommands := []string{
|
||||
|
|
|
|||
11
go.mod
11
go.mod
|
|
@ -14,6 +14,7 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
|
|
@ -25,6 +26,7 @@ require (
|
|||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
github.com/mymmrac/telego v1.8.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
|
|
@ -35,6 +37,7 @@ require (
|
|||
github.com/rs/zerolog v1.35.0
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
go.mau.fi/util v0.9.7
|
||||
|
|
@ -65,7 +68,12 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
|
@ -79,6 +87,7 @@ require (
|
|||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||
|
|
@ -88,10 +97,10 @@ require (
|
|||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
|
|
|
|||
19
go.sum
19
go.sum
|
|
@ -55,6 +55,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
|
|||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
|
|
@ -67,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
|
|||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
|
|
@ -179,12 +191,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
||||
|
|
@ -218,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
|
|
@ -276,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
|||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ const (
|
|||
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
|
||||
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||
sessionKeyAgentPrefix = "agent:"
|
||||
metadataKeyMessageKind = "message_kind"
|
||||
messageKindThought = "thought"
|
||||
metadataKeyAccountID = "account_id"
|
||||
metadataKeyGuildID = "guild_id"
|
||||
metadataKeyTeamID = "team_id"
|
||||
|
|
@ -1718,6 +1720,41 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
|
|||
return ""
|
||||
}
|
||||
|
||||
func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
|
||||
if reasoningContent == "" || chatID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer pubCancel()
|
||||
|
||||
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: "pico",
|
||||
ChatID: chatID,
|
||||
Content: reasoningContent,
|
||||
Metadata: map[string]string{
|
||||
metadataKeyMessageKind: messageKindThought,
|
||||
},
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, bus.ErrBusClosed) {
|
||||
logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
|
||||
"channel": "pico",
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
|
||||
"channel": "pico",
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) handleReasoning(
|
||||
ctx context.Context,
|
||||
reasoningContent, channelName, channelID string,
|
||||
|
|
@ -2319,12 +2356,16 @@ turnLoop:
|
|||
if reasoningContent == "" {
|
||||
reasoningContent = response.ReasoningContent
|
||||
}
|
||||
if ts.channel == "pico" {
|
||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||
} else {
|
||||
go al.handleReasoning(
|
||||
turnCtx,
|
||||
reasoningContent,
|
||||
ts.channel,
|
||||
al.targetReasoningChannelID(ts.channel),
|
||||
)
|
||||
}
|
||||
al.emitEvent(
|
||||
EventKindLLMResponse,
|
||||
ts.eventMeta("runTurn", "turn.llm.response"),
|
||||
|
|
@ -2373,7 +2414,7 @@ turnLoop:
|
|||
|
||||
if len(response.ToolCalls) == 0 || gracefulTerminal {
|
||||
responseContent := response.Content
|
||||
if responseContent == "" && response.ReasoningContent != "" {
|
||||
if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" {
|
||||
responseContent = response.ReasoningContent
|
||||
}
|
||||
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
|
||||
|
|
|
|||
|
|
@ -1972,7 +1972,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
|
|||
},
|
||||
{
|
||||
ModelName: "gemma-fallback",
|
||||
Model: "gemini/gemma-3-27b-it",
|
||||
Model: "openrouter/gemma-3-27b-it",
|
||||
APIBase: fallbackServer.URL,
|
||||
APIKeys: config.SimpleSecureStrings("fallback-key"),
|
||||
Workspace: workspace,
|
||||
|
|
@ -2711,6 +2711,62 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &reasoningContentProvider{
|
||||
response: "final answer",
|
||||
reasoningContent: "thinking trace",
|
||||
}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||
Channel: "pico",
|
||||
SenderID: "user1",
|
||||
ChatID: "pico:test-session",
|
||||
Content: "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("processMessage() error = %v", err)
|
||||
}
|
||||
if response != "final answer" {
|
||||
t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
|
||||
}
|
||||
|
||||
var thoughtMsg *bus.OutboundMessage
|
||||
deadline := time.After(3 * time.Second)
|
||||
|
||||
for thoughtMsg == nil {
|
||||
select {
|
||||
case outbound := <-msgBus.OutboundChan():
|
||||
msg := outbound
|
||||
if msg.Content == "thinking trace" {
|
||||
thoughtMsg = &msg
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("expected thought outbound message for pico")
|
||||
}
|
||||
}
|
||||
|
||||
if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
|
||||
t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
|
||||
}
|
||||
if thoughtMsg.Metadata[metadataKeyMessageKind] != messageKindThought {
|
||||
t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
|
||||
|
|
|
|||
|
|
@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
|
|||
}
|
||||
|
||||
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
|
||||
content, _ := msg.Payload["content"].(string)
|
||||
if isThoughtPayload(msg.Payload) {
|
||||
return
|
||||
}
|
||||
|
||||
content, _ := msg.Payload[PayloadKeyContent].(string)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
|
@ -285,7 +289,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) (
|
|||
}
|
||||
|
||||
outMsg := newMessage(TypeMessageSend, map[string]any{
|
||||
"content": msg.Content,
|
||||
PayloadKeyContent: msg.Content,
|
||||
})
|
||||
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
|
||||
return nil, pc.writeJSON(outMsg)
|
||||
|
|
|
|||
|
|
@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
|
|||
t.Fatal("timed out waiting for inbound media message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsThoughtPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "explicit thought bool",
|
||||
payload: map[string]any{PayloadKeyThought: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "thought false",
|
||||
payload: map[string]any{PayloadKeyThought: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "thought string ignored",
|
||||
payload: map[string]any{PayloadKeyThought: "true"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "default normal",
|
||||
payload: map[string]any{PayloadKeyContent: "hello"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isThoughtPayload(tt.payload); got != tt.want {
|
||||
t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
|
||||
mb := bus.NewMessageBus()
|
||||
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
||||
URL: "ws://localhost:8080/ws",
|
||||
}, mb)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPicoClientChannel() error = %v", err)
|
||||
}
|
||||
|
||||
ch.ctx = context.Background()
|
||||
pc := &picoConn{sessionID: "sess-thought"}
|
||||
|
||||
ch.handleServerMessage(pc, PicoMessage{
|
||||
Type: TypeMessageCreate,
|
||||
Payload: map[string]any{
|
||||
PayloadKeyContent: "internal reasoning",
|
||||
PayloadKeyThought: true,
|
||||
},
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-mb.InboundChan():
|
||||
t.Fatalf("expected no inbound publish for thought payload, got %+v", msg)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{
|
|||
"image/bmp": {},
|
||||
}
|
||||
|
||||
func outboundMessageIsThought(metadata map[string]string) bool {
|
||||
if len(metadata) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(metadata["message_kind"]), MessageKindThought)
|
||||
}
|
||||
|
||||
// writeJSON sends a JSON message to the connection with write locking.
|
||||
func (pc *picoConn) writeJSON(v any) error {
|
||||
if pc.closed.Load() {
|
||||
|
|
@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
|||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
isThought := outboundMessageIsThought(msg.Metadata)
|
||||
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
"content": msg.Content,
|
||||
PayloadKeyContent: msg.Content,
|
||||
PayloadKeyThought: isThought,
|
||||
})
|
||||
|
||||
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
||||
|
|
@ -288,7 +297,8 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
|
|||
|
||||
msgID := uuid.New().String()
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
"content": text,
|
||||
PayloadKeyContent: text,
|
||||
PayloadKeyThought: false,
|
||||
"message_id": msgID,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ const (
|
|||
TypePong = "pong"
|
||||
|
||||
PicoTokenPrefix = "pico-"
|
||||
|
||||
PayloadKeyContent = "content"
|
||||
PayloadKeyThought = "thought"
|
||||
|
||||
MessageKindThought = "thought"
|
||||
)
|
||||
|
||||
// PicoMessage is the wire format for all Pico Protocol messages.
|
||||
|
|
@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
|
|||
}
|
||||
}
|
||||
|
||||
func isThoughtPayload(payload map[string]any) bool {
|
||||
thought, _ := payload[PayloadKeyThought].(bool)
|
||||
return thought
|
||||
}
|
||||
|
||||
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
|
||||
payload := map[string]any{
|
||||
"code": code,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
||||
//go:build !mipsle && !netbsd && !(freebsd && arm) && !android
|
||||
|
||||
package gateway
|
||||
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ type antigravityJSONResponse struct {
|
|||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought bool `json:"thought,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
|
||||
|
|
@ -406,6 +407,7 @@ type antigravityJSONResponse struct {
|
|||
|
||||
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
||||
var contentParts []string
|
||||
var reasoningParts []string
|
||||
var toolCalls []ToolCall
|
||||
var usage *UsageInfo
|
||||
var finishReason string
|
||||
|
|
@ -433,8 +435,12 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
|||
for _, candidate := range resp.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
if part.Thought {
|
||||
reasoningParts = append(reasoningParts, part.Text)
|
||||
} else {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
|
|
@ -476,6 +482,7 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
|||
|
||||
return &LLMResponse{
|
||||
Content: strings.Join(contentParts, ""),
|
||||
ReasoningContent: strings.Join(reasoningParts, ""),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: mappedFinish,
|
||||
Usage: usage,
|
||||
|
|
|
|||
|
|
@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
|
|||
t.Fatalf("expected inferred tool name search_docs, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
|
||||
p := &AntigravityProvider{}
|
||||
body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" +
|
||||
"data: [DONE]\n"
|
||||
|
||||
resp, err := p.parseSSEResponse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEResponse() error = %v", err)
|
||||
}
|
||||
|
||||
if resp.Content != "visible answer" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "visible answer")
|
||||
}
|
||||
if resp.ReasoningContent != "hidden reasoning" {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 216 {
|
||||
t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
|
|||
|
||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
|
||||
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
|
||||
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
|
||||
// See the switch on protocol in this function for the authoritative list.
|
||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||
|
|
@ -218,7 +218,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
}
|
||||
return provider, modelID, nil
|
||||
|
||||
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
|
||||
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
||||
|
|
@ -242,6 +242,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
|
||||
case "gemini":
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
apiBase := cfg.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
return NewGeminiProvider(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
userAgent,
|
||||
cfg.RequestTimeout,
|
||||
cfg.ExtraBody,
|
||||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
|
||||
case "minimax":
|
||||
// Minimax requires reasoning_split: true in the request body
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||
|
|
|
|||
|
|
@ -434,6 +434,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_Gemini(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-gemini",
|
||||
Model: "gemini/gemini-2.5-flash",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "gemini-2.5-flash" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
|
||||
}
|
||||
if _, ok := provider.(*GeminiProvider); !ok {
|
||||
t.Fatalf("expected *GeminiProvider, got %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-gemini-no-key",
|
||||
Model: "gemini/gemini-2.5-flash",
|
||||
}
|
||||
|
||||
_, _, err := CreateProviderFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-gemini-custom-base",
|
||||
Model: "gemini/gemini-2.5-flash",
|
||||
APIBase: "https://proxy.example.com/v1beta",
|
||||
}
|
||||
|
||||
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||
}
|
||||
if modelID != "gemini-2.5-flash" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
|
||||
}
|
||||
if _, ok := provider.(*GeminiProvider); !ok {
|
||||
t.Fatalf("expected *GeminiProvider, got %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-claude-cli",
|
||||
|
|
|
|||
796
pkg/providers/gemini_provider.go
Normal file
796
pkg/providers/gemini_provider.go
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
geminiDefaultModel = "gemini-2.0-flash"
|
||||
)
|
||||
|
||||
type GeminiProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
extraBody map[string]any
|
||||
customHeaders map[string]string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func NewGeminiProvider(
|
||||
apiKey string,
|
||||
apiBase string,
|
||||
proxy string,
|
||||
userAgent string,
|
||||
requestTimeoutSeconds int,
|
||||
extraBody map[string]any,
|
||||
customHeaders map[string]string,
|
||||
) *GeminiProvider {
|
||||
if strings.TrimSpace(apiBase) == "" {
|
||||
apiBase = geminiDefaultAPIBase
|
||||
}
|
||||
client := common.NewHTTPClient(proxy)
|
||||
if requestTimeoutSeconds > 0 {
|
||||
client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
return &GeminiProvider{
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
|
||||
httpClient: client,
|
||||
extraBody: cloneAnyMap(extraBody),
|
||||
customHeaders: cloneStringMap(customHeaders),
|
||||
userAgent: strings.TrimSpace(userAgent),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) GetDefaultModel() string {
|
||||
return geminiDefaultModel
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) SupportsThinking() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
if p.apiBase == "" {
|
||||
return nil, fmt.Errorf("API base not configured")
|
||||
}
|
||||
|
||||
model = normalizeGeminiModel(model)
|
||||
requestBody := p.buildRequestBody(messages, tools, model, options)
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
p.applyHeaders(req)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
var apiResp geminiGenerateContentResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return parseGeminiResponse(&apiResp), nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatStream(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
onChunk func(accumulated string),
|
||||
) (*LLMResponse, error) {
|
||||
if p.apiBase == "" {
|
||||
return nil, fmt.Errorf("API base not configured")
|
||||
}
|
||||
|
||||
model = normalizeGeminiModel(model)
|
||||
requestBody := p.buildRequestBody(messages, tools, model, options)
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
p.applyHeaders(req)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
// Streaming should not use a whole-request timeout; context cancellation is the guard.
|
||||
streamClient := &http.Client{Transport: p.httpClient.Transport}
|
||||
resp, err := streamClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
}
|
||||
|
||||
return parseGeminiStreamResponse(ctx, resp.Body, onChunk)
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) applyHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.apiKey != "" {
|
||||
req.Header.Set("X-Goog-Api-Key", p.apiKey)
|
||||
}
|
||||
if p.userAgent != "" {
|
||||
req.Header.Set("User-Agent", p.userAgent)
|
||||
}
|
||||
for k, v := range p.customHeaders {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) buildRequestBody(
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) map[string]any {
|
||||
contents := make([]geminiContent, 0, len(messages))
|
||||
toolCallNames := make(map[string]string)
|
||||
systemPrompts := make([]string, 0, 1)
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
systemPrompts = append(systemPrompts, msg.Content)
|
||||
}
|
||||
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||
contents = append(contents, geminiContent{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{
|
||||
FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
|
||||
}},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := make([]geminiPart, 0, 1+len(msg.Media))
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
parts = append(parts, geminiPart{Text: msg.Content})
|
||||
}
|
||||
parts = append(parts, buildInlineMediaParts(msg.Media)...)
|
||||
if len(parts) > 0 {
|
||||
contents = append(contents, geminiContent{Role: "user", Parts: parts})
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
content := geminiContent{Role: "model"}
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
content.Parts = append(content.Parts, geminiPart{Text: msg.Content})
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
if tc.ID != "" {
|
||||
toolCallNames[tc.ID] = toolName
|
||||
}
|
||||
part := geminiPart{
|
||||
FunctionCall: &geminiFunctionCall{
|
||||
Name: toolName,
|
||||
Args: toolArgs,
|
||||
ID: tc.ID,
|
||||
},
|
||||
}
|
||||
if thoughtSignature != "" {
|
||||
part.ThoughtSignature = thoughtSignature
|
||||
}
|
||||
content.Parts = append(content.Parts, part)
|
||||
}
|
||||
if len(content.Parts) > 0 {
|
||||
contents = append(contents, content)
|
||||
}
|
||||
|
||||
case "tool":
|
||||
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
|
||||
contents = append(contents, geminiContent{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{
|
||||
FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"contents": contents,
|
||||
}
|
||||
if len(systemPrompts) > 0 {
|
||||
systemParts := make([]geminiPart, 0, len(systemPrompts))
|
||||
for _, prompt := range systemPrompts {
|
||||
systemParts = append(systemParts, geminiPart{Text: prompt})
|
||||
}
|
||||
body["systemInstruction"] = &geminiContent{Parts: systemParts}
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
funcDecls := make([]geminiFunctionDeclaration, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
if t.Type != "function" {
|
||||
continue
|
||||
}
|
||||
funcDecls = append(funcDecls, geminiFunctionDeclaration{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
Parameters: sanitizeSchemaForGemini(t.Function.Parameters),
|
||||
})
|
||||
}
|
||||
if len(funcDecls) > 0 {
|
||||
body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}}
|
||||
}
|
||||
}
|
||||
|
||||
generationConfig := make(map[string]any)
|
||||
if val, ok := options["max_tokens"]; ok {
|
||||
if maxTokens, ok := val.(int); ok && maxTokens > 0 {
|
||||
generationConfig["maxOutputTokens"] = maxTokens
|
||||
} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
|
||||
generationConfig["maxOutputTokens"] = int(maxTokens)
|
||||
}
|
||||
}
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
generationConfig["temperature"] = temp
|
||||
}
|
||||
|
||||
if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 {
|
||||
generationConfig["thinkingConfig"] = thinkingConfig
|
||||
}
|
||||
|
||||
if len(generationConfig) > 0 {
|
||||
body["generationConfig"] = generationConfig
|
||||
}
|
||||
|
||||
for k, v := range p.extraBody {
|
||||
body[k] = v
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func normalizeGeminiModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
model = strings.TrimPrefix(model, "models/")
|
||||
if strings.Contains(model, "/") {
|
||||
_, modelID := ExtractProtocol(model)
|
||||
if modelID != "" {
|
||||
return modelID
|
||||
}
|
||||
}
|
||||
if model == "" {
|
||||
return geminiDefaultModel
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func mapGeminiThinkingLevel(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "minimal", "off":
|
||||
return "minimal"
|
||||
case "low":
|
||||
return "low"
|
||||
case "medium":
|
||||
return "medium"
|
||||
case "high", "xhigh", "adaptive":
|
||||
return "high"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any {
|
||||
if !geminiModelSupportsThinkingConfig(model) {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := map[string]any{}
|
||||
rawLevel, _ := options["thinking_level"].(string)
|
||||
rawLevel = strings.ToLower(strings.TrimSpace(rawLevel))
|
||||
if rawLevel == "" {
|
||||
// Align with agent-level default: unset means ThinkingOff.
|
||||
rawLevel = "off"
|
||||
}
|
||||
|
||||
includeThoughts := rawLevel != "off" && rawLevel != "minimal"
|
||||
config["includeThoughts"] = includeThoughts
|
||||
|
||||
if isGemini25Model(model) {
|
||||
if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
|
||||
// Gemini 2.5 Pro cannot disable thinking; keep model-default thinking.
|
||||
return config
|
||||
}
|
||||
if budget, ok := mapGeminiThinkingBudget(rawLevel); ok {
|
||||
config["thinkingBudget"] = budget
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
|
||||
// Gemini 3.x Pro does not support minimal thinking level.
|
||||
return config
|
||||
}
|
||||
|
||||
if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" {
|
||||
config["thinkingLevel"] = thinkingLevel
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func geminiModelSupportsThinkingConfig(model string) bool {
|
||||
lowerModel := strings.ToLower(strings.TrimSpace(model))
|
||||
return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel)
|
||||
}
|
||||
|
||||
func isGemini25Model(model string) bool {
|
||||
lowerModel := strings.ToLower(strings.TrimSpace(model))
|
||||
return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25")
|
||||
}
|
||||
|
||||
func isGemini25ProModel(model string) bool {
|
||||
lowerModel := strings.ToLower(strings.TrimSpace(model))
|
||||
return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro")
|
||||
}
|
||||
|
||||
func isGemini3ProModel(model string) bool {
|
||||
lowerModel := strings.ToLower(strings.TrimSpace(model))
|
||||
return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro")
|
||||
}
|
||||
|
||||
func mapGeminiThinkingBudget(level string) (int, bool) {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch level {
|
||||
case "adaptive":
|
||||
return -1, true
|
||||
case "minimal":
|
||||
return 0, true
|
||||
case "off":
|
||||
return 0, true
|
||||
case "low":
|
||||
return 1024, true
|
||||
case "medium":
|
||||
return 4096, true
|
||||
case "high":
|
||||
return 8192, true
|
||||
case "xhigh":
|
||||
return 16384, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse {
|
||||
contentParts := make([]string, 0)
|
||||
reasoningParts := make([]string, 0)
|
||||
toolCalls := make([]ToolCall, 0)
|
||||
finishReason := ""
|
||||
|
||||
for _, candidate := range resp.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
if part.Thought {
|
||||
reasoningParts = append(reasoningParts, part.Text)
|
||||
} else {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
toolCalls = append(toolCalls, buildGeminiToolCall(part))
|
||||
}
|
||||
}
|
||||
if candidate.FinishReason != "" {
|
||||
finishReason = candidate.FinishReason
|
||||
}
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.Join(contentParts, ""),
|
||||
ReasoningContent: strings.Join(reasoningParts, ""),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func parseGeminiStreamResponse(
|
||||
ctx context.Context,
|
||||
reader io.Reader,
|
||||
onChunk func(accumulated string),
|
||||
) (*LLMResponse, error) {
|
||||
var contentBuilder strings.Builder
|
||||
var reasoningBuilder strings.Builder
|
||||
var finishReason string
|
||||
var usage *UsageInfo
|
||||
|
||||
toolCallsByID := make(map[string]ToolCall)
|
||||
toolCallOrder := make([]string, 0)
|
||||
fallbackIndex := 0
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk geminiGenerateContentResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("invalid gemini stream chunk: %w", err)
|
||||
}
|
||||
|
||||
for _, candidate := range chunk.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
if part.Thought {
|
||||
reasoningBuilder.WriteString(part.Text)
|
||||
} else {
|
||||
contentBuilder.WriteString(part.Text)
|
||||
if onChunk != nil {
|
||||
onChunk(contentBuilder.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
tc := buildGeminiToolCall(part)
|
||||
if strings.TrimSpace(tc.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(part.FunctionCall.ID)
|
||||
if key == "" {
|
||||
if len(toolCallOrder) > 0 {
|
||||
lastKey := toolCallOrder[len(toolCallOrder)-1]
|
||||
if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name {
|
||||
key = lastKey
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
fallbackIndex++
|
||||
key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex)
|
||||
}
|
||||
}
|
||||
|
||||
tc.ID = key
|
||||
if _, exists := toolCallsByID[key]; !exists {
|
||||
toolCallOrder = append(toolCallOrder, key)
|
||||
}
|
||||
toolCallsByID[key] = tc
|
||||
}
|
||||
}
|
||||
if candidate.FinishReason != "" {
|
||||
finishReason = candidate.FinishReason
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.UsageMetadata.TotalTokenCount > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: chunk.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("streaming read error: %w", err)
|
||||
}
|
||||
|
||||
toolCalls := make([]ToolCall, 0, len(toolCallOrder))
|
||||
for _, key := range toolCallOrder {
|
||||
toolCalls = append(toolCalls, toolCallsByID[key])
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: contentBuilder.String(),
|
||||
ReasoningContent: reasoningBuilder.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeGeminiFinishReason(reason string, toolCalls int) string {
|
||||
if toolCalls > 0 {
|
||||
return "tool_calls"
|
||||
}
|
||||
|
||||
switch strings.ToUpper(strings.TrimSpace(reason)) {
|
||||
case "MAX_TOKENS":
|
||||
return "length"
|
||||
case "", "STOP":
|
||||
return "stop"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(reason))
|
||||
}
|
||||
}
|
||||
|
||||
func buildGeminiToolCall(part geminiPart) ToolCall {
|
||||
if part.FunctionCall == nil {
|
||||
return ToolCall{}
|
||||
}
|
||||
|
||||
args := part.FunctionCall.Args
|
||||
if args == nil {
|
||||
args = make(map[string]any)
|
||||
}
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake)
|
||||
|
||||
toolCall := ToolCall{
|
||||
ID: part.FunctionCall.ID,
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: args,
|
||||
ThoughtSignature: thoughtSignature,
|
||||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argsJSON),
|
||||
ThoughtSignature: thoughtSignature,
|
||||
},
|
||||
}
|
||||
|
||||
if thoughtSignature != "" {
|
||||
toolCall.ExtraContent = &ExtraContent{
|
||||
Google: &GoogleExtra{ThoughtSignature: thoughtSignature},
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(toolCall.ID) == "" {
|
||||
toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return toolCall
|
||||
}
|
||||
|
||||
func buildInlineMediaParts(media []string) []geminiPart {
|
||||
parts := make([]geminiPart, 0, len(media))
|
||||
for _, mediaURL := range media {
|
||||
mimeType, data, ok := parseBase64DataURL(mediaURL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, geminiPart{
|
||||
InlineData: &geminiInlineData{
|
||||
MIMEType: mimeType,
|
||||
Data: data,
|
||||
},
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func buildGeminiFunctionResponse(
|
||||
toolName string,
|
||||
toolCallID string,
|
||||
result string,
|
||||
media []string,
|
||||
) *geminiFunctionResponse {
|
||||
response := &geminiFunctionResponse{
|
||||
ID: toolCallID,
|
||||
Name: toolName,
|
||||
Response: map[string]any{
|
||||
"result": result,
|
||||
},
|
||||
}
|
||||
|
||||
if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 {
|
||||
response.Parts = parts
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart {
|
||||
parts := make([]geminiFunctionResponsePart, 0, len(media))
|
||||
for i, mediaURL := range media {
|
||||
mimeType, data, ok := parseBase64DataURL(mediaURL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, geminiFunctionResponsePart{
|
||||
InlineData: &geminiInlineData{
|
||||
MIMEType: mimeType,
|
||||
Data: data,
|
||||
DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1),
|
||||
},
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func defaultFunctionResponseDisplayName(mimeType string, index int) string {
|
||||
suffix := "bin"
|
||||
switch strings.ToLower(strings.TrimSpace(mimeType)) {
|
||||
case "image/png":
|
||||
suffix = "png"
|
||||
case "image/jpeg":
|
||||
suffix = "jpg"
|
||||
case "image/webp":
|
||||
suffix = "webp"
|
||||
case "application/pdf":
|
||||
suffix = "pdf"
|
||||
case "text/plain":
|
||||
suffix = "txt"
|
||||
}
|
||||
return fmt.Sprintf("attachment-%d.%s", index, suffix)
|
||||
}
|
||||
|
||||
func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) {
|
||||
if !strings.HasPrefix(mediaURL, "data:") {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
payload := strings.TrimPrefix(mediaURL, "data:")
|
||||
header, data, found := strings.Cut(payload, ",")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
mimeType, params, _ := strings.Cut(header, ";")
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
data = strings.TrimSpace(data)
|
||||
if mimeType == "" || data == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(params), "base64") {
|
||||
return "", "", false
|
||||
}
|
||||
return mimeType, data, true
|
||||
}
|
||||
|
||||
func cloneAnyMap(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type geminiGenerateContentResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Role string `json:"role"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought bool `json:"thought,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
|
||||
InlineData *geminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type geminiInlineData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Response map[string]any `json:"response"`
|
||||
Parts []geminiFunctionResponsePart `json:"parts,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionResponsePart struct {
|
||||
InlineData *geminiInlineData `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiTool struct {
|
||||
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
|
||||
}
|
||||
|
||||
type geminiFunctionDeclaration struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters any `json:"parameters,omitempty"`
|
||||
}
|
||||
763
pkg/providers/gemini_provider_test.go
Normal file
763
pkg/providers/gemini_provider_test.go
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
|
||||
var capturedBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.Path, ":generateContent") {
|
||||
t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" {
|
||||
t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []any{
|
||||
map[string]any{
|
||||
"content": map[string]any{
|
||||
"role": "model",
|
||||
"parts": []any{
|
||||
map[string]any{"text": "hidden", "thought": true},
|
||||
map[string]any{"text": "visible"},
|
||||
map[string]any{
|
||||
"functionCall": map[string]any{
|
||||
"id": "call_1",
|
||||
"name": "search",
|
||||
"args": map[string]any{"q": "hi"},
|
||||
},
|
||||
"thoughtSignature": "sig-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
},
|
||||
},
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": 2,
|
||||
"candidatesTokenCount": 3,
|
||||
"totalTokenCount": 5,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil)
|
||||
resp, err := provider.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-3-flash-preview",
|
||||
map[string]any{"thinking_level": "high"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "visible" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "visible")
|
||||
}
|
||||
if resp.ReasoningContent != "hidden" {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden")
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 5 {
|
||||
t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "search" {
|
||||
t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search")
|
||||
}
|
||||
if resp.ToolCalls[0].ThoughtSignature != "sig-1" {
|
||||
t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1")
|
||||
}
|
||||
if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) {
|
||||
t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function)
|
||||
}
|
||||
|
||||
generationConfig, ok := capturedBody["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request missing generationConfig: %#v", capturedBody)
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request missing thinkingConfig: %#v", generationConfig)
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
|
||||
t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
if got := thinkingConfig["thinkingLevel"]; got != "high" {
|
||||
t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
|
||||
t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("alt"); got != "sse" {
|
||||
t.Fatalf("alt query = %q, want %q", got, "sse")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("response writer is not flushable")
|
||||
}
|
||||
|
||||
chunks := []map[string]any{
|
||||
{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{
|
||||
map[string]any{"text": "think ", "thought": true},
|
||||
map[string]any{"text": "Hello "},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{
|
||||
map[string]any{"text": "World"},
|
||||
map[string]any{
|
||||
"functionCall": map[string]any{
|
||||
"id": "call_stream",
|
||||
"name": "search",
|
||||
"args": map[string]any{"q": "stream"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": 1,
|
||||
"candidatesTokenCount": 2,
|
||||
"totalTokenCount": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
raw, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk: %v", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
|
||||
t.Fatalf("write chunk: %v", err)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
||||
updates := make([]string, 0)
|
||||
resp, err := provider.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
func(accumulated string) {
|
||||
updates = append(updates, accumulated)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello World" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "Hello World")
|
||||
}
|
||||
if resp.ReasoningContent != "think " {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ")
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" {
|
||||
t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
|
||||
t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage)
|
||||
}
|
||||
if len(updates) < 2 || updates[len(updates)-1] != "Hello World" {
|
||||
t.Fatalf("stream updates = %#v, expected final accumulated text", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("response writer is not flushable")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(w, "data: \n\n")
|
||||
flusher.Flush()
|
||||
|
||||
chunk := map[string]any{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{map[string]any{"text": "ok"}},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk: %v", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
|
||||
flusher.Flush()
|
||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
||||
resp, err := provider.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("response writer is not flushable")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(w, "data: {invalid-json}\n\n")
|
||||
flusher.Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
||||
_, err := provider.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("ChatStream() expected error for invalid SSE data frame")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid gemini stream chunk") {
|
||||
t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "search",
|
||||
Arguments: map[string]any{"q": "hello"},
|
||||
Function: &FunctionCall{
|
||||
Name: "search",
|
||||
Arguments: `{"q":"hello"}`,
|
||||
ThoughtSignature: "sig-1",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
)
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
jsonBody := string(raw)
|
||||
|
||||
if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
|
||||
t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
|
||||
}
|
||||
if strings.Contains(jsonBody, `"thought_signature"`) {
|
||||
t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("response writer is not flushable")
|
||||
}
|
||||
|
||||
chunks := []map[string]any{
|
||||
{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{
|
||||
map[string]any{
|
||||
"functionCall": map[string]any{
|
||||
"name": "search",
|
||||
"args": map[string]any{"q": "first"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
"candidates": []any{map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{
|
||||
map[string]any{
|
||||
"functionCall": map[string]any{
|
||||
"name": "search",
|
||||
"args": map[string]any{"q": "second"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
raw, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal chunk: %v", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
|
||||
t.Fatalf("write chunk: %v", err)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
||||
resp, err := provider.ChatStream(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error = %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.ID != "search#1" {
|
||||
t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1")
|
||||
}
|
||||
if tc.Name != "search" {
|
||||
t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search")
|
||||
}
|
||||
if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" {
|
||||
t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{
|
||||
Role: "user",
|
||||
Content: "analyze attachments",
|
||||
Media: []string{
|
||||
"data:application/pdf;base64,UEZERGF0YQ==",
|
||||
"data:image/png;base64,aW1hZ2VEYXRh",
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
"gemini-3-flash-preview",
|
||||
map[string]any{
|
||||
"thinking_level": "low",
|
||||
"max_tokens": 128,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
)
|
||||
|
||||
contents, ok := body["contents"].([]geminiContent)
|
||||
if !ok || len(contents) != 1 {
|
||||
t.Fatalf("contents = %#v, want one gemini content", body["contents"])
|
||||
}
|
||||
parts := contents[0].Parts
|
||||
mimeSet := map[string]bool{}
|
||||
for _, part := range parts {
|
||||
if part.InlineData != nil {
|
||||
mimeSet[part.InlineData.MIMEType] = true
|
||||
}
|
||||
}
|
||||
if !mimeSet["application/pdf"] {
|
||||
t.Fatalf("inline media missing application/pdf: %#v", parts)
|
||||
}
|
||||
if !mimeSet["image/png"] {
|
||||
t.Fatalf("inline media missing image/png: %#v", parts)
|
||||
}
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
if got := generationConfig["maxOutputTokens"]; got != 128 {
|
||||
t.Fatalf("maxOutputTokens = %#v, want 128", got)
|
||||
}
|
||||
if got := generationConfig["temperature"]; got != 0.2 {
|
||||
t.Fatalf("temperature = %#v, want 0.2", got)
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
|
||||
t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
if got := thinkingConfig["thinkingLevel"]; got != "low" {
|
||||
t.Fatalf("thinkingLevel = %#v, want %q", got, "low")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
map[string]any{"thinking_level": "medium"},
|
||||
)
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if got := thinkingConfig["thinkingBudget"]; got != 4096 {
|
||||
t.Fatalf("thinkingBudget = %#v, want 4096", got)
|
||||
}
|
||||
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
|
||||
t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.0-flash-exp",
|
||||
map[string]any{"thinking_level": "high"},
|
||||
)
|
||||
|
||||
if _, ok := body["generationConfig"]; ok {
|
||||
t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
)
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if got := thinkingConfig["thinkingBudget"]; got != 0 {
|
||||
t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got)
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
||||
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-3-flash-preview",
|
||||
nil,
|
||||
)
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if got := thinkingConfig["thinkingLevel"]; got != "minimal" {
|
||||
t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got)
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
||||
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-pro",
|
||||
nil,
|
||||
)
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
||||
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget {
|
||||
t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-3.1-pro",
|
||||
nil,
|
||||
)
|
||||
|
||||
generationConfig, ok := body["generationConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
||||
}
|
||||
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
||||
}
|
||||
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
||||
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
||||
}
|
||||
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
|
||||
t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "system", Content: "Be concise."},
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
nil,
|
||||
"gemini-3-flash-preview",
|
||||
nil,
|
||||
)
|
||||
|
||||
systemInstruction, ok := body["systemInstruction"].(*geminiContent)
|
||||
if !ok || systemInstruction == nil {
|
||||
t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"])
|
||||
}
|
||||
if len(systemInstruction.Parts) != 2 {
|
||||
t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts))
|
||||
}
|
||||
if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." {
|
||||
t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) {
|
||||
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
||||
body := provider.buildRequestBody(
|
||||
[]Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "load_image",
|
||||
Arguments: map[string]any{"path": "demo.png"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
ToolCallID: "call_1",
|
||||
Content: "tool result",
|
||||
Media: []string{
|
||||
"data:image/png;base64,aW1hZ2VEYXRh",
|
||||
"data:application/pdf;base64,UEZERGF0YQ==",
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
"gemini-3-flash-preview",
|
||||
nil,
|
||||
)
|
||||
|
||||
contents, ok := body["contents"].([]geminiContent)
|
||||
if !ok || len(contents) != 2 {
|
||||
t.Fatalf("contents = %#v, want two content entries", body["contents"])
|
||||
}
|
||||
parts := contents[1].Parts
|
||||
if len(parts) != 1 || parts[0].FunctionResponse == nil {
|
||||
t.Fatalf("tool response part = %#v, want functionResponse", parts)
|
||||
}
|
||||
response := parts[0].FunctionResponse
|
||||
if response.Name != "load_image" {
|
||||
t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image")
|
||||
}
|
||||
if response.Response["result"] != "tool result" {
|
||||
t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response)
|
||||
}
|
||||
if len(response.Parts) != 2 {
|
||||
t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||
}
|
||||
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
|
||||
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []any{
|
||||
map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": []any{map[string]any{"text": "ok"}},
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider(
|
||||
"",
|
||||
server.URL,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
nil,
|
||||
map[string]string{"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
resp, err := provider.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
|
||||
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []any{
|
||||
map[string]any{
|
||||
"content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}},
|
||||
"finishReason": "STOP",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil)
|
||||
resp, err := provider.Chat(
|
||||
t.Context(),
|
||||
[]Message{{Role: "user", Content: "hello"}},
|
||||
nil,
|
||||
"gemini-2.5-flash",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
|
@ -181,9 +182,7 @@ func (p *Provider) buildRequestBody(
|
|||
|
||||
// Merge extra body fields configured per-provider/model.
|
||||
// These are injected last so they take precedence over defaults.
|
||||
for k, v := range p.extraBody {
|
||||
requestBody[k] = v
|
||||
}
|
||||
maps.Copy(requestBody, p.extraBody)
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
|
|
|||
27
web/Makefile
27
web/Makefile
|
|
@ -1,4 +1,5 @@
|
|||
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
|
||||
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
|
||||
build-android-arm64 build-android-bundle
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
|
|
@ -9,7 +10,9 @@ GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
|||
# Build variables
|
||||
BUILD_DIR=build
|
||||
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
|
||||
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
|
||||
FRONTEND_DIR=frontend
|
||||
FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
|
||||
BACKEND_DIR=backend
|
||||
BACKEND_DIST=$(BACKEND_DIR)/dist
|
||||
PICOCLAW_BINARY_NAME=picoclaw
|
||||
|
|
@ -91,12 +94,26 @@ build: build-frontend
|
|||
@mkdir -p "$$(dirname "$(OUTPUT)")"
|
||||
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
|
||||
|
||||
# Build launcher for Android ARM64 (frontend must already be built)
|
||||
build-android-arm64: build-frontend
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
|
||||
|
||||
# Build launcher for all Android architectures
|
||||
build-android-bundle: build-frontend
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
|
||||
@echo "All Android launcher builds complete"
|
||||
|
||||
build-frontend:
|
||||
@if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \
|
||||
@expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
|
||||
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
|
||||
[ ! -f $(FRONTEND_INSTALL_STAMP) ] || \
|
||||
[ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \
|
||||
echo "Installing frontend dependencies..."; \
|
||||
cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \
|
||||
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
|
||||
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
|
||||
fi
|
||||
@echo "Building frontend..."
|
||||
@cd $(FRONTEND_DIR) && pnpm build:backend
|
||||
|
|
|
|||
|
|
@ -281,6 +281,12 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
}
|
||||
|
||||
case "assistant":
|
||||
// Reasoning-only assistant messages are transient display artifacts and
|
||||
// should not be restored from session history.
|
||||
if assistantMessageTransientThought(msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(toolSummaryMessages) > 0 {
|
||||
transcript = append(transcript, toolSummaryMessages...)
|
||||
|
|
@ -309,6 +315,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
return transcript
|
||||
}
|
||||
|
||||
func assistantMessageTransientThought(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) == "" &&
|
||||
strings.TrimSpace(msg.ReasoningContent) != "" &&
|
||||
len(msg.ToolCalls) == 0 &&
|
||||
len(msg.Media) == 0
|
||||
}
|
||||
|
||||
func assistantMessageInternalOnly(msg providers.Message) bool {
|
||||
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,59 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
sessionKey := picoSessionPrefix + "detail-transient-thought"
|
||||
for _, msg := range []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", ReasoningContent: "internal chain of thought"},
|
||||
{Role: "assistant", Content: "final visible answer"},
|
||||
} {
|
||||
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
|
||||
t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
|
||||
}
|
||||
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
|
||||
t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build (!darwin && !freebsd) || cgo
|
||||
//go:build !android && ((!darwin && !freebsd) || cgo)
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//go:build (darwin || freebsd) && !cgo
|
||||
//go:build (darwin || freebsd || android) && !cgo
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
|
|
@ -7,16 +8,20 @@ import remarkGfm from "remark-gfm"
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
isThought?: boolean
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
content,
|
||||
isThought = false,
|
||||
timestamp = "",
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
|
@ -33,6 +38,12 @@ export function AssistantMessage({
|
|||
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>PicoClaw</span>
|
||||
{isThought && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/80 bg-amber-100/80 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
<IconBrain className="size-3" />
|
||||
<span>{t("chat.reasoningLabel")}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedTimestamp && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
|
|
@ -42,8 +53,22 @@ export function AssistantMessage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
|
||||
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed [overflow-wrap:anywhere] break-words">
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border",
|
||||
isThought
|
||||
? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
: "bg-card text-card-foreground",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none [overflow-wrap:anywhere] break-words",
|
||||
isThought
|
||||
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
|
||||
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
|
|
@ -54,7 +79,12 @@ export function AssistantMessage({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className={cn(
|
||||
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isThought
|
||||
? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30"
|
||||
: "bg-background/50 hover:bg-background/80",
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ export function ChatPage() {
|
|||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
isThought={msg.kind === "thought"}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export async function loadSessionMessages(
|
|||
id: `hist-${index}-${Date.now()}`,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
kind: message.role === "assistant" ? "normal" : undefined,
|
||||
attachments: toChatAttachments(message.media),
|
||||
timestamp: fallbackTime,
|
||||
}))
|
||||
|
|
@ -50,7 +51,7 @@ function messageSignature(message: ChatMessage): string {
|
|||
|
||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||
message.timestamp,
|
||||
)}\u0000${attachmentSignature}`
|
||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
|
||||
}
|
||||
|
||||
function comparableTimestamp(timestamp: number | string): number {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import { updateChatStore } from "@/store/chat"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
updateChatStore,
|
||||
} from "@/store/chat"
|
||||
|
||||
export interface PicoMessage {
|
||||
type: string
|
||||
|
|
@ -11,6 +14,16 @@ export interface PicoMessage {
|
|||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseAssistantMessageKind(
|
||||
payload: Record<string, unknown>,
|
||||
): AssistantMessageKind {
|
||||
return payload.thought === true ? "thought" : "normal"
|
||||
}
|
||||
|
||||
function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
||||
return typeof payload.thought === "boolean"
|
||||
}
|
||||
|
||||
export function handlePicoMessage(
|
||||
message: PicoMessage,
|
||||
expectedSessionId: string,
|
||||
|
|
@ -25,6 +38,7 @@ export function handlePicoMessage(
|
|||
case "message.create": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const timestamp =
|
||||
message.timestamp !== undefined &&
|
||||
Number.isFinite(Number(message.timestamp))
|
||||
|
|
@ -38,6 +52,7 @@ export function handlePicoMessage(
|
|||
id: messageId,
|
||||
role: "assistant",
|
||||
content,
|
||||
kind,
|
||||
timestamp,
|
||||
},
|
||||
],
|
||||
|
|
@ -49,13 +64,21 @@ export function handlePicoMessage(
|
|||
case "message.update": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = payload.message_id as string
|
||||
const hasKind = hasAssistantKindPayload(payload)
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
if (!messageId) {
|
||||
break
|
||||
}
|
||||
|
||||
updateChatStore((prev) => ({
|
||||
messages: prev.messages.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, content } : msg,
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
content,
|
||||
...(hasKind ? { kind } : {}),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
}))
|
||||
break
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"step3": "Preparing response...",
|
||||
"step4": "Almost there..."
|
||||
},
|
||||
"reasoningLabel": "Reasoning",
|
||||
"history": "History",
|
||||
"noHistory": "No chat history yet",
|
||||
"historyLoadFailed": "Failed to load chat history",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"step3": "准备回复...",
|
||||
"step4": "马上就好..."
|
||||
},
|
||||
"reasoningLabel": "思考",
|
||||
"history": "历史记录",
|
||||
"noHistory": "暂无对话历史",
|
||||
"historyLoadFailed": "加载历史记录失败",
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ export interface ChatAttachment {
|
|||
filename?: string
|
||||
}
|
||||
|
||||
export type AssistantMessageKind = "normal" | "thought"
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: number | string
|
||||
kind?: AssistantMessageKind
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue