Rewrite search mechanism to adapt more common

Added ollama search
Enhance search result by agent before return to user
This commit is contained in:
PhotoPortfolio Developer 2026-02-19 02:48:02 +08:00
parent 8d757fbb6f
commit 7d623fc3af
6 changed files with 247 additions and 50 deletions

View file

@ -3,7 +3,7 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model": "glm-4.7", "model": "ollama/qwen2.5:14b-instruct ",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -116,7 +116,11 @@
"tools": { "tools": {
"web": { "web": {
"search": { "search": {
"api_key": "YOUR_BRAVE_API_KEY", "provider": "ollama",
"api_key": "77b893700a1d4c8dad9a7326be9a76d6.7pl0DA9ojPa_6UCMMZ_Sk-Cn",
"endpoint": "https://ollama.com/api/web_search",
"rest_type": "POST",
"query_param": "query",
"max_results": 5 "max_results": 5
} }
} }

View file

@ -73,13 +73,24 @@ 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))
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ // Build web search tool from config - single provider with fallback to DuckDuckGo
BraveAPIKey: cfg.Tools.Web.Brave.APIKey, searchOpts := []tools.WebSearchToolOptions{
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, {
BraveEnabled: cfg.Tools.Web.Brave.Enabled, Provider: cfg.Tools.Web.Search.Provider,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, APIKey: cfg.Tools.Web.Search.APIKey,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, BaseURL: cfg.Tools.Web.Search.Endpoint,
}); searchTool != nil { MaxResults: cfg.Tools.Web.Search.MaxResults,
Mode: cfg.Tools.Web.Search.RestType,
Param: cfg.Tools.Web.Search.QueryParam,
},
// Always add DuckDuckGo as fallback
{
Provider: "duckduckgo",
MaxResults: 5,
},
}
if searchTool := tools.NewWebSearchTool(searchOpts...); searchTool != nil {
registry.Register(searchTool) registry.Register(searchTool)
} }
registry.Register(tools.NewWebFetchTool(50000)) registry.Register(tools.NewWebFetchTool(50000))

View file

@ -206,9 +206,25 @@ type DuckDuckGoConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
} }
type OllamaConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_OLLAMA_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_OLLAMA_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_OLLAMA_MAX_RESULTS"`
}
// WebSearchConfig defines a single active web search provider
// Falls back to DuckDuckGo if the primary provider fails
type WebSearchConfig struct {
Provider string `json:"provider" env:"PICOCLAW_TOOLS_WEB_SEARCH_PROVIDER"` // "brave", "ollama", custom URL
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"` // API key for Brave, etc.
Endpoint string `json:"endpoint" env:"PICOCLAW_TOOLS_WEB_SEARCH_ENDPOINT"` // Base URL for Ollama or custom
RestType string `json:"rest_type" env:"PICOCLAW_TOOLS_WEB_SEARCH_REST_TYPE"` // "GET" or "POST"
QueryParam string `json:"query_param" env:"PICOCLAW_TOOLS_WEB_SEARCH_QUERY_PARAM"` // "q", "query", etc.
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"` // Default: 5
}
type WebToolsConfig struct { type WebToolsConfig struct {
Brave BraveConfig `json:"brave"` Search WebSearchConfig `json:"search"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
} }
type ToolsConfig struct { type ToolsConfig struct {
@ -312,13 +328,12 @@ func DefaultConfig() *Config {
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
Web: WebToolsConfig{ Web: WebToolsConfig{
Brave: BraveConfig{ Search: WebSearchConfig{
Enabled: false, Provider: "ollama",
APIKey: "", APIKey: "77b893700a1d4c8dad9a7326be9a76d6.7pl0DA9ojPa_6UCMMZ_Sk-Cn",
MaxResults: 5, Endpoint: "https://ollama.com/api/web_search",
}, RestType: "POST",
DuckDuckGo: DuckDuckGoConfig{ QueryParam: "query",
Enabled: true,
MaxResults: 5, MaxResults: 5,
}, },
}, },

View file

@ -212,17 +212,24 @@ 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.Brave.APIKey = v cfg.Tools.Web.Search.APIKey = v
if v != "" {
cfg.Tools.Web.Brave.Enabled = true
} }
if v, ok := getString(search, "provider"); ok {
cfg.Tools.Web.Search.Provider = v
}
if v, ok := getString(search, "endpoint"); ok {
cfg.Tools.Web.Search.Endpoint = v
}
if v, ok := getString(search, "rest_type"); ok {
cfg.Tools.Web.Search.RestType = v
}
if v, ok := getString(search, "query_param"); ok {
cfg.Tools.Web.Search.QueryParam = v
} }
if v, ok := getFloat(search, "max_results"); ok { if v, ok := getFloat(search, "max_results"); ok {
cfg.Tools.Web.Brave.MaxResults = int(v) cfg.Tools.Web.Search.MaxResults = int(v)
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
} }
} }
} }
@ -276,8 +283,23 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
existing.Channels.MaixCam = incoming.Channels.MaixCam existing.Channels.MaixCam = incoming.Channels.MaixCam
} }
if existing.Tools.Web.Brave.APIKey == "" { if existing.Tools.Web.Search.APIKey == "" {
existing.Tools.Web.Brave = incoming.Tools.Web.Brave existing.Tools.Web.Search.APIKey = incoming.Tools.Web.Search.APIKey
}
if existing.Tools.Web.Search.Provider == "" {
existing.Tools.Web.Search.Provider = incoming.Tools.Web.Search.Provider
}
if existing.Tools.Web.Search.Endpoint == "" {
existing.Tools.Web.Search.Endpoint = incoming.Tools.Web.Search.Endpoint
}
if existing.Tools.Web.Search.RestType == "" {
existing.Tools.Web.Search.RestType = incoming.Tools.Web.Search.RestType
}
if existing.Tools.Web.Search.QueryParam == "" {
existing.Tools.Web.Search.QueryParam = incoming.Tools.Web.Search.QueryParam
}
if existing.Tools.Web.Search.MaxResults == 0 {
existing.Tools.Web.Search.MaxResults = incoming.Tools.Web.Search.MaxResults
} }
return existing return existing

View file

@ -176,44 +176,156 @@ func stripTags(content string) string {
return re.ReplaceAllString(content, "") return re.ReplaceAllString(content, "")
} }
type OllamaSearchProvider struct {
baseURL string
apiKey string
queryParam string // Parameter name for query (e.g., "query", "q")
}
func (p *OllamaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
// Call Ollama REST API for web search
requestBody := map[string]interface{}{
p.queryParam: query,
}
bodyJSON, _ := json.Marshal(requestBody)
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL, strings.NewReader(string(bodyJSON)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.apiKey))
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request to Ollama 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 the raw JSON string for the agent/tool to process
return string(body), nil
}
type WebSearchTool struct { type WebSearchTool struct {
provider SearchProvider provider SearchProvider
maxResults int maxResults int
} }
// WebSearchToolOptions defines configuration for web search providers.
// This is the unified API for all search providers.
//
// If Provider is set, it's considered enabled. Use empty Provider to disable.
// BaseURL is generic and works for any provider (e.g., custom Ollama instances).
// Model is not needed for web search - results are translated by the main LLM.
//
// Usage example:
//
// opts := []WebSearchToolOptions{
// {Provider: "brave", APIKey: "key", MaxResults: 5},
// {Provider: "ollama", BaseURL: "http://localhost:11434", MaxResults: 5},
// {Provider: "duckduckgo", MaxResults: 5},
// }
// tool := NewWebSearchTool(opts...)
type WebSearchToolOptions struct { type WebSearchToolOptions struct {
BraveAPIKey string Provider string // "brave", "ollama", "duckduckgo"
BraveMaxResults int APIKey string // For Brave API
BraveEnabled bool BaseURL string // For custom providers (e.g., Ollama)
DuckDuckGoMaxResults int MaxResults int // Default: 5
DuckDuckGoEnabled bool Mode string // "GET" or "POST" (reserved for future use)
Param string // Query param name: "q", "query", etc. (reserved for future use)
} }
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { func NewWebSearchTool(opts ...WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider // Priority order: Brave > Ollama > DuckDuckGo
maxResults := 5 priorityOrder := []string{"brave", "ollama", "duckduckgo"}
optMap := make(map[string]WebSearchToolOptions)
// Priority: Brave > DuckDuckGo // Build map of enabled providers
if opts.BraveEnabled && opts.BraveAPIKey != "" { for _, opt := range opts {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey} if opt.Provider != "" {
if opts.BraveMaxResults > 0 { optMap[opt.Provider] = opt
maxResults = opts.BraveMaxResults
} }
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
} }
} else {
var selectedOpt *WebSearchToolOptions
var provider SearchProvider
// Try providers in priority order
for _, providerName := range priorityOrder {
opt, exists := optMap[providerName]
if !exists {
continue
}
selectedOpt = &opt
var err error
provider, err = createProvider(&opt)
if err == nil && provider != nil {
break
}
}
if provider == nil {
return nil return nil
} }
maxResults := 5
if selectedOpt != nil && selectedOpt.MaxResults > 0 {
maxResults = selectedOpt.MaxResults
}
return &WebSearchTool{ return &WebSearchTool{
provider: provider, provider: provider,
maxResults: maxResults, maxResults: maxResults,
} }
} }
// createProvider creates a SearchProvider based on configuration
// Provider is considered enabled if non-empty
func createProvider(opt *WebSearchToolOptions) (SearchProvider, error) {
if opt == nil || opt.Provider == "" {
return nil, fmt.Errorf("provider not set")
}
switch opt.Provider {
case "brave":
if opt.APIKey == "" {
return nil, fmt.Errorf("Brave API key required")
}
return &BraveSearchProvider{apiKey: opt.APIKey}, nil
case "ollama":
if opt.BaseURL == "" {
return nil, fmt.Errorf("Ollama BaseURL required")
}
queryParam := opt.Param
if queryParam == "" {
queryParam = "query" // Default query parameter
}
return &OllamaSearchProvider{
baseURL: opt.BaseURL,
apiKey: opt.APIKey, // Bearer token for API authentication
queryParam: queryParam,
}, nil
case "duckduckgo":
return &DuckDuckGoSearchProvider{}, nil
default:
return nil, fmt.Errorf("unknown provider: %s", opt.Provider)
}
}
func (t *WebSearchTool) Name() string { func (t *WebSearchTool) Name() string {
return "web_search" return "web_search"
} }
@ -259,6 +371,38 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
return ErrorResult(fmt.Sprintf("search failed: %v", err)) return ErrorResult(fmt.Sprintf("search failed: %v", err))
} }
// If Ollama, synthesize a readable answer for the user
if _, ok := t.provider.(*OllamaSearchProvider); ok {
var parsed struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
} `json:"results"`
}
if err := json.Unmarshal([]byte(result), &parsed); err == nil && len(parsed.Results) > 0 {
// Synthesize a readable answer
var summary []string
summary = append(summary, fmt.Sprintf("Web search summary for: %s", query))
maxItems := count
if len(parsed.Results) < count {
maxItems = len(parsed.Results)
}
for i := 0; i < maxItems; i++ {
r := parsed.Results[i]
summary = append(summary, fmt.Sprintf("%d. %s\n %s", i+1, r.Title, r.URL))
if r.Content != "" {
summary = append(summary, fmt.Sprintf(" %s", r.Content))
}
}
return &ToolResult{
ForLLM: result, // raw for LLM
ForUser: strings.Join(summary, "\n"),
}
}
}
// Default: return as-is
return &ToolResult{ return &ToolResult{
ForLLM: result, ForLLM: result,
ForUser: result, ForUser: result,

View file

@ -175,21 +175,22 @@ func TestWebTool_WebFetch_Truncation(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 := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) // Brave without API key should return nil
tool := NewWebSearchTool(WebSearchToolOptions{Provider: "brave", APIKey: ""})
if tool != nil { if tool != nil {
t.Errorf("Expected nil tool when Brave API key is empty") t.Errorf("Expected nil tool when Brave API key is empty")
} }
// Also nil when nothing is enabled // No providers should return nil
tool = NewWebSearchTool(WebSearchToolOptions{}) tool = NewWebSearchTool()
if tool != nil { if tool != nil {
t.Errorf("Expected nil tool when no provider is enabled") t.Errorf("Expected nil tool when no providers are provided")
} }
} }
// 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 := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) tool := NewWebSearchTool(WebSearchToolOptions{Provider: "duckduckgo", Enabled: true, MaxResults: 5})
ctx := context.Background() ctx := context.Background()
args := map[string]interface{}{} args := map[string]interface{}{}