feat: the assistant's search engine in Настройки - DuckDuckGo, a SearXNG of one's own, Brave Search or Tavily, with DuckDuckGo behind the chosen one

This commit is contained in:
redtea committed 2026-10-16 10:38:12 +03:00
1 parent 9bf30b281e
commit 9912255289
6 files changed
+200 -2

No files matched your search

+123 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
@@ -74,6 +75,127 @@ func parseDDG(page string) []searchResult {
var searchClient = &http.Client{Timeout: 15 * time.Second}
// The engine is the admins' choice (Настройки › ИИ-ассистент): DuckDuckGo,
// a SearXNG of one's own, Brave Search or Tavily; when the chosen one fails,
// DuckDuckGo answers instead.
const (
cfgSearchEngine = "ai_search"
cfgSearchURL = "ai_search_url"
cfgSearchKey = "ai_search_key"
)
type searchConf struct{ Engine, URL, Key string }
func (s *Server) searchConf() searchConf {
e, _ := s.db.GetConfig(cfgSearchEngine)
u, _ := s.db.GetConfig(cfgSearchURL)
k, _ := s.db.GetConfig(cfgSearchKey)
return searchConf{Engine: e, URL: strings.TrimRight(u, "/"), Key: k}
}
var (
braveURL = "https://api.search.brave.com/res/v1/web/search"
tavilyURL = "https://api.tavily.com/search"
ddgSearch = webSearch
)
// searchWith asks the engine c names; DuckDuckGo if that one fails.
func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
var rs []searchResult
var err error
switch c.Engine {
case "searxng":
rs, err = searchSearx(ctx, c, q)
case "brave":
rs, err = searchBrave(ctx, c, q, n)
case "tavily":
rs, err = searchTavily(ctx, c, q, n)
default:
return ddgSearch(ctx, q, n)
}
if err != nil || len(rs) == 0 {
return ddgSearch(ctx, q, n)
}
if len(rs) > n {
rs = rs[:n]
}
return rs, nil
}
// getJSON fetches url (with the headers) into v.
func getJSON(ctx context.Context, method, url string, body io.Reader, hdr map[string]string, v any) error {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
for k, val := range hdr {
req.Header.Set(k, val)
}
resp, err := searchClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return errors.New("поисковик ответил " + resp.Status)
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(v)
}
// searchSearx: a SearXNG with JSON output on (search.formats: json).
func searchSearx(ctx context.Context, c searchConf, q string) ([]searchResult, error) {
if c.URL == "" {
return nil, errors.New("не задан адрес SearXNG")
}
var out struct {
Results []struct{ Title, URL, Content string } `json:"results"`
}
if err := getJSON(ctx, http.MethodGet, c.URL+"/search?format=json&q="+url.QueryEscape(q), nil, nil, &out); err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Results {
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: text(r.Content)})
}
return rs, nil
}
func searchBrave(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
var out struct {
Web struct {
Results []struct{ Title, URL, Description string } `json:"results"`
} `json:"web"`
}
err := getJSON(ctx, http.MethodGet, braveURL+"?count="+strconv.Itoa(n)+"&q="+url.QueryEscape(q), nil,
map[string]string{"X-Subscription-Token": c.Key}, &out)
if err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Web.Results {
rs = append(rs, searchResult{Title: text(r.Title), URL: r.URL, Snippet: text(r.Description)})
}
return rs, nil
}
func searchTavily(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
b, _ := json.Marshal(map[string]any{"query": q, "max_results": n})
var out struct {
Results []struct{ Title, URL, Content string } `json:"results"`
}
err := getJSON(ctx, http.MethodPost, tavilyURL, strings.NewReader(string(b)),
map[string]string{"Authorization": "Bearer " + c.Key, "Content-Type": "application/json"}, &out)
if err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Results {
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: r.Content})
}
return rs, nil
}
// webSearch asks DuckDuckGo for q: up to n results.
func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
var last error
@@ -119,7 +241,7 @@ func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
return
}
rs, err := webSearch(r.Context(), strings.TrimSpace(in.Q), 6)
rs, err := searchWith(r.Context(), s.searchConf(), strings.TrimSpace(in.Q), 6)
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return