Merge branch 'main' into patch-1
This commit is contained in:
52
README.md
52
README.md
@@ -66,7 +66,7 @@
|
|||||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
| --- | --- | --- |--- |
|
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||||
| **Language** | TypeScript | Python | **Go** |
|
| **Language** | TypeScript | Python | **Go** |
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||||
@@ -100,9 +100,9 @@
|
|||||||
|
|
||||||
PicoClaw can be deployed on almost any Linux device!
|
PicoClaw can be deployed on almost any Linux device!
|
||||||
|
|
||||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
|
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
|
||||||
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
|
||||||
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
|
||||||
|
|
||||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month)
|
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
@@ -206,9 +206,14 @@ picoclaw onboard
|
|||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"brave": {
|
||||||
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +242,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||||||
Talk to your picoclaw through Telegram, Discord, or DingTalk
|
Talk to your picoclaw through Telegram, Discord, or DingTalk
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
|---------|-------|
|
| ------------ | -------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
@@ -597,7 +602,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
@@ -630,8 +635,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
"zhipu": {
|
"zhipu": {
|
||||||
"api_key": "Your API Key",
|
"api_key": "Your API Key",
|
||||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -692,8 +697,14 @@ picoclaw agent -m "Hello"
|
|||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"brave": {
|
||||||
"api_key": "BSA..."
|
"enabled": false,
|
||||||
|
"api_key": "BSA...",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -709,7 +720,7 @@ picoclaw agent -m "Hello"
|
|||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ------------------------- | ----------------------------- |
|
||||||
| `picoclaw onboard` | Initialize config & workspace |
|
| `picoclaw onboard` | Initialize config & workspace |
|
||||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||||
| `picoclaw agent` | Interactive chat mode |
|
| `picoclaw agent` | Interactive chat mode |
|
||||||
@@ -750,16 +761,23 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
|
|||||||
|
|
||||||
To enable web search:
|
To enable web search:
|
||||||
|
|
||||||
1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month)
|
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
||||||
2. Add to `~/.picoclaw/config.json`:
|
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
||||||
|
|
||||||
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
"search": {
|
"brave": {
|
||||||
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"duckduckgo": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +797,7 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
|||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
|---------|-----------|-----------|
|
| ---------------- | ------------------- | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||||
|
|||||||
@@ -70,9 +70,15 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
|||||||
// Shell execution
|
// Shell execution
|
||||||
registry.Register(tools.NewExecTool(workspace, restrict))
|
registry.Register(tools.NewExecTool(workspace, restrict))
|
||||||
|
|
||||||
// Web tools
|
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
braveAPIKey := cfg.Tools.Web.Search.APIKey
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
registry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
}); searchTool != nil {
|
||||||
|
registry.Register(searchTool)
|
||||||
|
}
|
||||||
registry.Register(tools.NewWebFetchTool(50000))
|
registry.Register(tools.NewWebFetchTool(50000))
|
||||||
|
|
||||||
// Message tool - available to both agent and subagent
|
// Message tool - available to both agent and subagent
|
||||||
|
|||||||
@@ -59,7 +59,22 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
|
|||||||
for _, allowed := range c.allowList {
|
for _, allowed := range c.allowList {
|
||||||
// Strip leading "@" from allowed value for username matching
|
// Strip leading "@" from allowed value for username matching
|
||||||
trimmed := strings.TrimPrefix(allowed, "@")
|
trimmed := strings.TrimPrefix(allowed, "@")
|
||||||
if senderID == allowed || idPart == allowed || senderID == trimmed || idPart == trimmed || (userPart != "" && (userPart == allowed || userPart == trimmed)) {
|
allowedID := trimmed
|
||||||
|
allowedUser := ""
|
||||||
|
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||||
|
allowedID = trimmed[:idx]
|
||||||
|
allowedUser = trimmed[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support either side using "id|username" compound form.
|
||||||
|
// This keeps backward compatibility with legacy Telegram allowlist entries.
|
||||||
|
if senderID == allowed ||
|
||||||
|
idPart == allowed ||
|
||||||
|
senderID == trimmed ||
|
||||||
|
idPart == trimmed ||
|
||||||
|
idPart == allowedID ||
|
||||||
|
(allowedUser != "" && senderID == allowedUser) ||
|
||||||
|
(userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
pkg/channels/base_test.go
Normal file
53
pkg/channels/base_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowList []string
|
||||||
|
senderID string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty allowlist allows all",
|
||||||
|
allowList: nil,
|
||||||
|
senderID: "anyone",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound sender matches numeric allowlist",
|
||||||
|
allowList: []string{"123456"},
|
||||||
|
senderID: "123456|alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound sender matches username allowlist",
|
||||||
|
allowList: []string{"@alice"},
|
||||||
|
senderID: "123456|alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric sender matches legacy compound allowlist",
|
||||||
|
allowList: []string{"123456|alice"},
|
||||||
|
senderID: "123456",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non matching sender is denied",
|
||||||
|
allowList: []string{"123456"},
|
||||||
|
senderID: "654321|bob",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
||||||
|
if got := ch.IsAllowed(tt.senderID); got != tt.want {
|
||||||
|
t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -177,15 +177,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := fmt.Sprintf("%d", user.ID)
|
userID := fmt.Sprintf("%d", user.ID)
|
||||||
|
senderID := userID
|
||||||
if user.Username != "" {
|
if user.Username != "" {
|
||||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
senderID = fmt.Sprintf("%s|%s", userID, user.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件
|
// 检查白名单,避免为被拒绝的用户下载附件
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
|
||||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||||
"user_id": senderID,
|
"user_id": userID,
|
||||||
|
"username": user.Username,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -359,7 +361,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||||
|
|||||||
@@ -164,13 +164,20 @@ type GatewayConfig struct {
|
|||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchConfig struct {
|
type BraveConfig struct {
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuckDuckGoConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Search WebSearchConfig `json:"search"`
|
Brave BraveConfig `json:"brave"`
|
||||||
|
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
@@ -257,10 +264,15 @@ func DefaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
Search: WebSearchConfig{
|
Brave: BraveConfig{
|
||||||
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Heartbeat: HeartbeatConfig{
|
Heartbeat: HeartbeatConfig{
|
||||||
|
|||||||
@@ -212,12 +212,17 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||||||
|
|
||||||
if tools, ok := getMap(data, "tools"); ok {
|
if tools, ok := getMap(data, "tools"); ok {
|
||||||
if web, ok := getMap(tools, "web"); ok {
|
if web, ok := getMap(tools, "web"); ok {
|
||||||
|
// Migrate old "search" config to "brave" if api_key is present
|
||||||
if search, ok := getMap(web, "search"); ok {
|
if search, ok := getMap(web, "search"); ok {
|
||||||
if v, ok := getString(search, "api_key"); ok {
|
if v, ok := getString(search, "api_key"); ok {
|
||||||
cfg.Tools.Web.Search.APIKey = v
|
cfg.Tools.Web.Brave.APIKey = v
|
||||||
|
if v != "" {
|
||||||
|
cfg.Tools.Web.Brave.Enabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := getFloat(search, "max_results"); ok {
|
if v, ok := getFloat(search, "max_results"); ok {
|
||||||
cfg.Tools.Web.Search.MaxResults = int(v)
|
cfg.Tools.Web.Brave.MaxResults = int(v)
|
||||||
|
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,8 +276,8 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
|
|||||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.Tools.Web.Search.APIKey == "" {
|
if existing.Tools.Web.Brave.APIKey == "" {
|
||||||
existing.Tools.Web.Search = incoming.Tools.Web.Search
|
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
||||||
}
|
}
|
||||||
|
|
||||||
return existing
|
return existing
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
|||||||
|
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API error: %s", string(body))
|
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.parseResponse(body)
|
return p.parseResponse(body)
|
||||||
|
|||||||
268
pkg/tools/web.go
268
pkg/tools/web.go
@@ -13,20 +13,210 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)"
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSearchTool struct {
|
type SearchProvider interface {
|
||||||
|
Search(ctx context.Context, query string, count int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BraveSearchProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||||
|
url.QueryEscape(query), count)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Subscription-Token", p.apiKey)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Web struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"results"`
|
||||||
|
} `json:"web"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
// Log error body for debugging
|
||||||
|
fmt.Printf("Brave API Error Body: %s\n", string(body))
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := searchResp.Web.Results
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Results for: %s", query))
|
||||||
|
for i, item := range results {
|
||||||
|
if i >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
||||||
|
if item.Description != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s", item.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuckDuckGoSearchProvider struct{}
|
||||||
|
|
||||||
|
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.extractResults(string(body), count, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
|
||||||
|
// Simple regex based extraction for DDG HTML
|
||||||
|
// Strategy: Find all result containers or key anchors directly
|
||||||
|
|
||||||
|
// Try finding the result links directly first, as they are the most critical
|
||||||
|
// Pattern: <a class="result__a" href="...">Title</a>
|
||||||
|
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
||||||
|
reLink := regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
|
||||||
|
matches := reLink.FindAllStringSubmatch(html, count+5)
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query))
|
||||||
|
|
||||||
|
// Pre-compile snippet regex to run inside the loop
|
||||||
|
// We'll search for snippets relative to the link position or just globally if needed
|
||||||
|
// But simple global search for snippets might mismatch order.
|
||||||
|
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
|
||||||
|
// Or better: Let's assume the snippet follows the link in the HTML
|
||||||
|
|
||||||
|
// A better regex approach: iterate through text and find matches in order
|
||||||
|
// But for now, let's grab all snippets too
|
||||||
|
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||||
|
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
|
||||||
|
|
||||||
|
maxItems := min(len(matches), count)
|
||||||
|
|
||||||
|
for i := 0; i < maxItems; i++ {
|
||||||
|
urlStr := matches[i][1]
|
||||||
|
title := stripTags(matches[i][2])
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
|
||||||
|
// URL decoding if needed
|
||||||
|
if strings.Contains(urlStr, "uddg=") {
|
||||||
|
if u, err := url.QueryUnescape(urlStr); err == nil {
|
||||||
|
idx := strings.Index(u, "uddg=")
|
||||||
|
if idx != -1 {
|
||||||
|
urlStr = u[idx+5:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
|
||||||
|
|
||||||
|
// Attempt to attach snippet if available and index aligns
|
||||||
|
if i < len(snippetMatches) {
|
||||||
|
snippet := stripTags(snippetMatches[i][1])
|
||||||
|
snippet = strings.TrimSpace(snippet)
|
||||||
|
if snippet != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s", snippet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripTags(content string) string {
|
||||||
|
re := regexp.MustCompile(`<[^>]+>`)
|
||||||
|
return re.ReplaceAllString(content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebSearchTool struct {
|
||||||
|
provider SearchProvider
|
||||||
maxResults int
|
maxResults int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
|
type WebSearchToolOptions struct {
|
||||||
if maxResults <= 0 || maxResults > 10 {
|
BraveAPIKey string
|
||||||
maxResults = 5
|
BraveMaxResults int
|
||||||
|
BraveEnabled bool
|
||||||
|
DuckDuckGoMaxResults int
|
||||||
|
DuckDuckGoEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
|
||||||
|
var provider SearchProvider
|
||||||
|
maxResults := 5
|
||||||
|
|
||||||
|
// Priority: Brave > DuckDuckGo
|
||||||
|
if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
||||||
|
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
|
||||||
|
if opts.BraveMaxResults > 0 {
|
||||||
|
maxResults = opts.BraveMaxResults
|
||||||
|
}
|
||||||
|
} else if opts.DuckDuckGoEnabled {
|
||||||
|
provider = &DuckDuckGoSearchProvider{}
|
||||||
|
if opts.DuckDuckGoMaxResults > 0 {
|
||||||
|
maxResults = opts.DuckDuckGoMaxResults
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return &WebSearchTool{
|
return &WebSearchTool{
|
||||||
apiKey: apiKey,
|
provider: provider,
|
||||||
maxResults: maxResults,
|
maxResults: maxResults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +249,6 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
if t.apiKey == "" {
|
|
||||||
return ErrorResult("BRAVE_API_KEY not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
query, ok := args["query"].(string)
|
query, ok := args["query"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("query is required")
|
return ErrorResult("query is required")
|
||||||
@@ -75,68 +261,14 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
result, err := t.provider.Search(ctx, query, count)
|
||||||
url.QueryEscape(query), count)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
|
return ErrorResult(fmt.Sprintf("search failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("X-Subscription-Token", t.apiKey)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Web struct {
|
|
||||||
Results []struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"results"`
|
|
||||||
} `json:"web"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
results := searchResp.Web.Results
|
|
||||||
if len(results) == 0 {
|
|
||||||
msg := fmt.Sprintf("No results for: %s", query)
|
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: msg,
|
ForLLM: result,
|
||||||
ForUser: msg,
|
ForUser: result,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, fmt.Sprintf("Results for: %s", query))
|
|
||||||
for i, item := range results {
|
|
||||||
if i >= count {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
|
||||||
if item.Description != "" {
|
|
||||||
lines = append(lines, fmt.Sprintf(" %s", item.Description))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := strings.Join(lines, "\n")
|
|
||||||
return &ToolResult{
|
|
||||||
ForLLM: fmt.Sprintf("Found %d results for: %s", len(results), query),
|
|
||||||
ForUser: output,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user