package searxng import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "net/url" "strings" "trankilou.fr/lassistanoque/backend/internal/domain" ) type SearxngConfig struct { URL string `json:"url"` } type SearxngTool struct { } func (t *SearxngTool) Name() string { return "searxng" } func (t *SearxngTool) Description() string { return "Search the web using a SearXNG instance. Returns a list of relevant results with title, URL, and snippet." } func (t *SearxngTool) Params() []domain.ToolParam { return []domain.ToolParam{ {JsonName: "url", Type: "string", Mandatory: true, Description: "Searxng URL"}, } } func (t *SearxngTool) Definition(ctx context.Context) *domain.ToolDefinition { return &domain.ToolDefinition{ Type: "function", Function: &domain.ToolFunction{ Name: t.Name(), Description: t.Description(), Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "The search query", }, "categories": map[string]any{ "type": "string", "description": "Comma-separated list of categories: general, news, images, science, it, social_media. Defaults to 'general'.", }, "language": map[string]any{ "type": "string", "description": "Language code for results, e.g. 'fr', 'en'. Defaults to 'all'.", }, }, "required": []string{"query"}, }, }, } } type searxngResponse struct { Results []searxngResult `json:"results"` } type searxngResult struct { Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` Engine string `json:"engine"` } func (t *SearxngTool) Execute(ctx context.Context, input []byte, configuration string) ([]byte, error) { slog.Debug("[searxng]", "input", string(input)) var params struct { Query string `json:"query"` Categories string `json:"categories"` Language string `json:"language"` } if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("invalid parameters: %w", err) } var toolCfg SearxngConfig err := json.Unmarshal([]byte(configuration), &toolCfg) if err != nil { return nil, fmt.Errorf("searxng tool not configured: %w", err) } if toolCfg.URL == "" { return nil, fmt.Errorf("searxng tool config missing 'url' field") } baseURL := toolCfg.URL categories := "general" if params.Categories != "" { categories = params.Categories } language := "all" if params.Language != "" { language = params.Language } reqURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=%s&language=%s", strings.TrimRight(baseURL, "/"), url.QueryEscape(params.Query), url.QueryEscape(categories), url.QueryEscape(language), ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to build request: %w", err) } req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("searxng request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("searxng returned status %d", resp.StatusCode) } var searchResp searxngResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if len(searchResp.Results) == 0 { return []byte("No results found."), nil } var sb strings.Builder fmt.Fprintf(&sb, "Search results for \"%s\":\n\n", params.Query) for i, r := range searchResp.Results { fmt.Fprintf(&sb, "%d. %s\n", i+1, r.Title) fmt.Fprintf(&sb, " URL: %s\n", r.URL) if r.Content != "" { fmt.Fprintf(&sb, " %s\n", r.Content) } fmt.Fprintf(&sb, "\n") } return []byte(sb.String()), nil }