Merge branch 'main' into main
This commit is contained in:
commit
da361b914e
23 changed files with 732 additions and 330 deletions
|
|
@ -18,10 +18,10 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -125,6 +125,23 @@ dockers_v2:
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
- linux/riscv64
|
- linux/riscv64
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
images:
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
|
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
||||||
|
tags:
|
||||||
|
- "{{ .Tag }}-launcher"
|
||||||
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
- linux/riscv64
|
||||||
|
|
||||||
notarize:
|
notarize:
|
||||||
macos:
|
macos:
|
||||||
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -11,8 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Launcher Mode (Web Console)
|
||||||
|
|
||||||
|
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = "🦞"
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
gitCommit string
|
|
||||||
buildTime string
|
|
||||||
goVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
|
|
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
// Deprecated: Use pkg/config.FormatVersion instead
|
||||||
func FormatVersion() string {
|
func FormatVersion() string {
|
||||||
v := version
|
return config.FormatVersion()
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBuildInfo returns build time and go version info
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||||
func FormatBuildInfo() (string, string) {
|
func FormatBuildInfo() (string, string) {
|
||||||
build := buildTime
|
return config.FormatBuildInfo()
|
||||||
goVer := goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return build, goVer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion returns the version string
|
// GetVersion returns the version string
|
||||||
|
// Deprecated: Use pkg/config.GetVersion instead
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return version
|
return config.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = ""
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = "abc123"
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "2026-02-20T00:00:00Z"
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, buildTime, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = ""
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Empty(t, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "x"
|
|
||||||
goVersion = ""
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, "x", build)
|
|
||||||
assert.Equal(t, runtime.Version(), goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_Windows(t *testing.T) {
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
|
@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
||||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
assert.Equal(t, "dev", GetVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_WithEnv(t *testing.T) {
|
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
|
||||||
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
|
||||||
|
|
||||||
got := GetConfigPath()
|
|
||||||
want := "/tmp/custom/config.json"
|
|
||||||
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := internal.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewVersionCommand() *cobra.Command {
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := internal.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf(" Build: %s\n", build)
|
fmt.Printf(" Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewPicoclawCommand(t *testing.T) {
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
assert.Equal(t, "picoclaw", cmd.Use)
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,9 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"api_keys": [
|
||||||
|
"YOUR_BRAVE_API_KEY"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -298,7 +301,10 @@
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "",
|
"api_key": "pplx-xxx",
|
||||||
|
"api_keys": [
|
||||||
|
"pplx-xxx"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
|
||||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
|
CMD ["-public", "-no-browser"]
|
||||||
|
|
@ -19,7 +19,7 @@ services:
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
|
|
@ -32,3 +32,21 @@ services:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root/.picoclaw
|
- ./data:/root/.picoclaw
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Launcher (Web Console + Gateway)
|
||||||
|
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-launcher:
|
||||||
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
|
container_name: picoclaw-launcher
|
||||||
|
restart: on-failure
|
||||||
|
profiles:
|
||||||
|
- launcher
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18800:18800"
|
||||||
|
- "127.0.0.1:18790:18790"
|
||||||
|
volumes:
|
||||||
|
- ./data:/root/.picoclaw
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
|
@ -79,8 +80,10 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
toolDiscovery := cb.getDiscoveryRule()
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
|
version := config.FormatVersion()
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(
|
||||||
|
`# picoclaw 🦞 (%s)
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
|
|
@ -102,7 +105,7 @@ Your workspace is at: %s
|
||||||
|
|
||||||
5. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.
|
5. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.
|
||||||
%s`,
|
%s`,
|
||||||
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getDiscoveryRule() string {
|
func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||||
|
|
|
||||||
|
|
@ -154,16 +154,19 @@ func registerSharedTools(
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("web") {
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
|
cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
cfg.Tools.Web.Perplexity.APIKeys,
|
||||||
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
@ -58,6 +59,16 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
type BuildInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for Config
|
// MarshalJSON implements custom JSON marshaling for Config
|
||||||
|
|
@ -596,12 +607,14 @@ type ToolConfig struct {
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
@ -614,6 +627,7 @@ type DuckDuckGoConfig struct {
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||||
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -935,6 +949,29 @@ func (c *Config) ValidateModelList() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var all []string
|
||||||
|
|
||||||
|
if k := strings.TrimSpace(apiKey); k != "" {
|
||||||
|
if _, exists := seen[k]; !exists {
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
all = append(all, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range apiKeys {
|
||||||
|
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
||||||
|
if _, exists := seen[trimmed]; !exists {
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
all = append(all, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "web":
|
case "web":
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Brave.APIKey != "" {
|
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
||||||
t.Error("Brave API key should be empty by default")
|
t.Error("Brave API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,13 @@ func DefaultConfig() *Config {
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
Tavily: TavilyConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
|
@ -393,6 +400,7 @@ func DefaultConfig() *Config {
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
SearXNG: SearXNGConfig{
|
SearXNG: SearXNGConfig{
|
||||||
|
|
@ -502,5 +510,11 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
BuildInfo: BuildInfo{
|
||||||
|
Version: Version,
|
||||||
|
GitCommit: GitCommit,
|
||||||
|
BuildTime: BuildTime,
|
||||||
|
GoVersion: GoVersion,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
pkg/config/version.go
Normal file
44
pkg/config/version.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build-time variables injected via ldflags during build process.
|
||||||
|
// These are set by the Makefile or .goreleaser.yaml using the -X flag:
|
||||||
|
//
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.Version=<version>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>
|
||||||
|
var (
|
||||||
|
Version = "dev" // Default value when not built with ldflags
|
||||||
|
GitCommit string // Git commit SHA (short)
|
||||||
|
BuildTime string // Build timestamp in RFC3339 format
|
||||||
|
GoVersion string // Go version used for building
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
func FormatVersion() string {
|
||||||
|
v := Version
|
||||||
|
if GitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", GitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
func FormatBuildInfo() (string, string) {
|
||||||
|
build := BuildTime
|
||||||
|
goVer := GoVersion
|
||||||
|
if goVer == "" {
|
||||||
|
goVer = runtime.Version()
|
||||||
|
}
|
||||||
|
return build, goVer
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the version string
|
||||||
|
func GetVersion() string {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
92
pkg/config/version_test.go
Normal file
92
pkg/config/version_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "2026-02-20T00:00:00Z"
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, BuildTime, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = ""
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "x"
|
||||||
|
GoVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "dev"
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion_Custom(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "v1.0.0"
|
||||||
|
assert.Equal(t, "v1.0.0", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion_DefaultIsDev(t *testing.T) {
|
||||||
|
// Reset to default values
|
||||||
|
oldVersion := Version
|
||||||
|
Version = "dev"
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
assert.Equal(t, "dev", Version)
|
||||||
|
}
|
||||||
|
|
@ -735,12 +735,14 @@ type WebToolsConfig struct {
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
|
APIKeys []string `json:"api_keys"`
|
||||||
MaxResults int `json:"max_results"`
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
|
APIKeys []string `json:"api_keys"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
MaxResults int `json:"max_results"`
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
@ -753,6 +755,7 @@ type DuckDuckGoConfig struct {
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
|
APIKeys []string `json:"api_keys"`
|
||||||
MaxResults int `json:"max_results"`
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
|
||||||
Brave: config.BraveConfig{
|
Brave: config.BraveConfig{
|
||||||
Enabled: c.Web.Brave.Enabled,
|
Enabled: c.Web.Brave.Enabled,
|
||||||
APIKey: c.Web.Brave.APIKey,
|
APIKey: c.Web.Brave.APIKey,
|
||||||
|
APIKeys: c.Web.Brave.APIKeys,
|
||||||
MaxResults: c.Web.Brave.MaxResults,
|
MaxResults: c.Web.Brave.MaxResults,
|
||||||
},
|
},
|
||||||
Tavily: config.TavilyConfig{
|
Tavily: config.TavilyConfig{
|
||||||
|
|
|
||||||
165
pkg/tools/web.go
165
pkg/tools/web.go
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -76,12 +77,50 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIKeyPool struct {
|
||||||
|
keys []string
|
||||||
|
current uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyPool(keys []string) *APIKeyPool {
|
||||||
|
return &APIKeyPool{
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyIterator struct {
|
||||||
|
pool *APIKeyPool
|
||||||
|
startIdx uint32
|
||||||
|
attempt uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *APIKeyPool) NewIterator() *APIKeyIterator {
|
||||||
|
if len(p.keys) == 0 {
|
||||||
|
return &APIKeyIterator{pool: p}
|
||||||
|
}
|
||||||
|
idx := atomic.AddUint32(&p.current, 1) - 1
|
||||||
|
return &APIKeyIterator{
|
||||||
|
pool: p,
|
||||||
|
startIdx: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *APIKeyIterator) Next() (string, bool) {
|
||||||
|
length := uint32(len(it.pool.keys))
|
||||||
|
if length == 0 || it.attempt >= length {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key := it.pool.keys[(it.startIdx+it.attempt)%length]
|
||||||
|
it.attempt++
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
type SearchProvider interface {
|
type SearchProvider interface {
|
||||||
Search(ctx context.Context, query string, count int) (string, error)
|
Search(ctx context.Context, query string, count int) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveSearchProvider struct {
|
type BraveSearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
@ -90,27 +129,46 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||||
url.QueryEscape(query), count)
|
url.QueryEscape(query), count)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
req.Header.Set("X-Subscription-Token", apiKey)
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("brave api error (status %d): %s", resp.StatusCode, string(body))
|
lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
|
|
@ -125,7 +183,6 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
// Log error body for debugging
|
// Log error body for debugging
|
||||||
fmt.Printf("Brave API Error Body: %s\n", string(body))
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,8 +206,11 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
return strings.Join(lines, "\n"), nil
|
return strings.Join(lines, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
type TavilySearchProvider struct {
|
type TavilySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
baseURL string
|
baseURL string
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
@ -162,8 +222,17 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
searchURL = "https://api.tavily.com/search"
|
searchURL = "https://api.tavily.com/search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"api_key": p.apiKey,
|
"api_key": apiKey,
|
||||||
"query": query,
|
"query": query,
|
||||||
"search_depth": "advanced",
|
"search_depth": "advanced",
|
||||||
"include_answer": false,
|
"include_answer": false,
|
||||||
|
|
@ -187,17 +256,27 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
|
|
@ -232,6 +311,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
return strings.Join(lines, "\n"), nil
|
return strings.Join(lines, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
type DuckDuckGoSearchProvider struct {
|
type DuckDuckGoSearchProvider struct {
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
@ -324,7 +406,7 @@ func stripTags(content string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +414,15 @@ type PerplexitySearchProvider struct {
|
||||||
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
searchURL := "https://api.perplexity.ai/chat/completions"
|
searchURL := "https://api.perplexity.ai/chat/completions"
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
iter := p.keyPool.NewIterator()
|
||||||
|
|
||||||
|
for {
|
||||||
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": "sonar",
|
"model": "sonar",
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
|
|
@ -358,22 +449,32 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("Perplexity API error: %s", string(body))
|
lastErr = fmt.Errorf("Perplexity API error: %s", string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp struct {
|
var searchResp struct {
|
||||||
|
|
@ -395,6 +496,9 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
type SearXNGSearchProvider struct {
|
type SearXNGSearchProvider struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
@ -545,16 +649,16 @@ type WebSearchTool struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchToolOptions struct {
|
type WebSearchToolOptions struct {
|
||||||
BraveAPIKey string
|
BraveAPIKeys []string
|
||||||
BraveMaxResults int
|
BraveMaxResults int
|
||||||
BraveEnabled bool
|
BraveEnabled bool
|
||||||
TavilyAPIKey string
|
TavilyAPIKeys []string
|
||||||
TavilyBaseURL string
|
TavilyBaseURL string
|
||||||
TavilyMaxResults int
|
TavilyMaxResults int
|
||||||
TavilyEnabled bool
|
TavilyEnabled bool
|
||||||
DuckDuckGoMaxResults int
|
DuckDuckGoMaxResults int
|
||||||
DuckDuckGoEnabled bool
|
DuckDuckGoEnabled bool
|
||||||
PerplexityAPIKey string
|
PerplexityAPIKeys []string
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
SearXNGBaseURL string
|
SearXNGBaseURL string
|
||||||
|
|
@ -571,23 +675,26 @@ type WebSearchToolOptions struct {
|
||||||
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
var provider SearchProvider
|
var provider SearchProvider
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
||||||
}
|
}
|
||||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client}
|
provider = &PerplexitySearchProvider{
|
||||||
|
keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
|
||||||
|
proxy: opts.Proxy,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
if opts.PerplexityMaxResults > 0 {
|
if opts.PerplexityMaxResults > 0 {
|
||||||
maxResults = opts.PerplexityMaxResults
|
maxResults = opts.PerplexityMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
||||||
}
|
}
|
||||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client}
|
provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
|
||||||
if opts.BraveMaxResults > 0 {
|
if opts.BraveMaxResults > 0 {
|
||||||
maxResults = opts.BraveMaxResults
|
maxResults = opts.BraveMaxResults
|
||||||
}
|
}
|
||||||
|
|
@ -596,13 +703,13 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
if opts.SearXNGMaxResults > 0 {
|
if opts.SearXNGMaxResults > 0 {
|
||||||
maxResults = opts.SearXNGMaxResults
|
maxResults = opts.SearXNGMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
||||||
}
|
}
|
||||||
provider = &TavilySearchProvider{
|
provider = &TavilySearchProvider{
|
||||||
apiKey: opts.TavilyAPIKey,
|
keyPool: NewAPIKeyPool(opts.TavilyAPIKeys),
|
||||||
baseURL: opts.TavilyBaseURL,
|
baseURL: opts.TavilyBaseURL,
|
||||||
proxy: opts.Proxy,
|
proxy: opts.Proxy,
|
||||||
client: client,
|
client: client,
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +269,11 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
BraveEnabled: true,
|
||||||
|
BraveAPIKeys: []string{"test-key"},
|
||||||
|
BraveMaxResults: 5,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -553,7 +557,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
t.Run("perplexity", func(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
PerplexityEnabled: true,
|
PerplexityEnabled: true,
|
||||||
PerplexityAPIKey: "k",
|
PerplexityAPIKeys: []string{"k"},
|
||||||
PerplexityMaxResults: 3,
|
PerplexityMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
|
@ -572,7 +576,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("brave", func(t *testing.T) {
|
t.Run("brave", func(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
BraveEnabled: true,
|
BraveEnabled: true,
|
||||||
BraveAPIKey: "k",
|
BraveAPIKeys: []string{"k"},
|
||||||
BraveMaxResults: 3,
|
BraveMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
|
@ -650,7 +654,7 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
TavilyEnabled: true,
|
TavilyEnabled: true,
|
||||||
TavilyAPIKey: "test-key",
|
TavilyAPIKeys: []string{"test-key"},
|
||||||
TavilyBaseURL: server.URL,
|
TavilyBaseURL: server.URL,
|
||||||
TavilyMaxResults: 5,
|
TavilyMaxResults: 5,
|
||||||
})
|
})
|
||||||
|
|
@ -682,6 +686,121 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyPool(t *testing.T) {
|
||||||
|
pool := NewAPIKeyPool([]string{"key1", "key2", "key3"})
|
||||||
|
if len(pool.keys) != 3 {
|
||||||
|
t.Fatalf("expected 3 keys, got %d", len(pool.keys))
|
||||||
|
}
|
||||||
|
if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" {
|
||||||
|
t.Fatalf("unexpected keys: %v", pool.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Iterator: each iterator should cover all keys exactly once
|
||||||
|
iter := pool.NewIterator()
|
||||||
|
expected := []string{"key1", "key2", "key3"}
|
||||||
|
for i, want := range expected {
|
||||||
|
k, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("iter.Next() returned false at step %d", i)
|
||||||
|
}
|
||||||
|
if k != want {
|
||||||
|
t.Errorf("step %d: expected %s, got %s", i, want, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Should be exhausted
|
||||||
|
if _, ok := iter.Next(); ok {
|
||||||
|
t.Errorf("expected iterator exhausted after all keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second iterator starts at next position (load balancing)
|
||||||
|
iter2 := pool.NewIterator()
|
||||||
|
k, ok := iter2.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("iter2.Next() returned false")
|
||||||
|
}
|
||||||
|
if k != "key2" {
|
||||||
|
t.Errorf("expected key2 (round-robin), got %s", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty pool
|
||||||
|
emptyPool := NewAPIKeyPool([]string{})
|
||||||
|
emptyIter := emptyPool.NewIterator()
|
||||||
|
if _, ok := emptyIter.Next(); ok {
|
||||||
|
t.Errorf("expected false for empty pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single key pool
|
||||||
|
singlePool := NewAPIKeyPool([]string{"single"})
|
||||||
|
singleIter := singlePool.NewIterator()
|
||||||
|
if k, ok := singleIter.Next(); !ok || k != "single" {
|
||||||
|
t.Errorf("expected single, got %s (ok=%v)", k, ok)
|
||||||
|
}
|
||||||
|
if _, ok := singleIter.Next(); ok {
|
||||||
|
t.Errorf("expected exhausted after single key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebTool_TavilySearch_Failover(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := payload["api_key"].(string)
|
||||||
|
|
||||||
|
if apiKey == "key1" {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("Rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "key2" {
|
||||||
|
// Success
|
||||||
|
response := map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{
|
||||||
|
"title": "Success Result",
|
||||||
|
"url": "https://example.com/success",
|
||||||
|
"content": "Success content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
TavilyEnabled: true,
|
||||||
|
TavilyAPIKeys: []string{"key1", "key2"},
|
||||||
|
TavilyBaseURL: server.URL,
|
||||||
|
TavilyMaxResults: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"query": "test query",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got Error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForUser, "Success Result") {
|
||||||
|
t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ PicoClaw 🦞
|
||||||
## Description
|
## Description
|
||||||
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
||||||
|
|
||||||
## Version
|
|
||||||
0.1.0
|
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
- Provide intelligent AI assistance with minimal resource usage
|
- Provide intelligent AI assistance with minimal resource usage
|
||||||
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue