diff --git a/internal/server/ai.go b/internal/server/ai.go index ce5d31b..889cb99 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -679,6 +679,14 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) { _ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model"))) // The field shows the key there; what it holds is the key (empty: none). _ = s.db.SetConfig(cfgAIKey, strings.TrimSpace(r.FormValue("ai_key"))) + // The search engine for «Поиск». + eng := r.FormValue("ai_search") + if eng != "searxng" && eng != "brave" && eng != "tavily" { + eng = "" + } + _ = s.db.SetConfig(cfgSearchEngine, eng) + _ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url"))) + _ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key"))) http.Redirect(w, r, "/settings#ai", http.StatusSeeOther) } diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 7c9b250..ea34272 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -444,6 +444,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "ServerVersion": Version, "AI": s.aiConfig(), + "Search": s.searchConf(), "DBNow": dbNow, "DBChosen": dbChosen, "UfwInstalled": ufwInstalled, diff --git a/internal/server/search.go b/internal/server/search.go index c661e15..2fa0306 100644 --- a/internal/server/search.go +++ b/internal/server/search.go @@ -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 diff --git a/internal/server/search_test.go b/internal/server/search_test.go index add841b..37d686a 100644 --- a/internal/server/search_test.go +++ b/internal/server/search_test.go @@ -1,6 +1,11 @@ package server import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -23,3 +28,50 @@ func TestParseDDGLite(t *testing.T) { t.Fatalf("first: %+v", r) } } + +// Each search engine of the settings, and DuckDuckGo when the chosen one +// fails. +func TestSearchProviders(t *testing.T) { + var gotKey, gotQ string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/search" && r.URL.Query().Get("format") == "json": // SearXNG + gotQ = r.URL.Query().Get("q") + fmt.Fprint(w, `{"results":[{"title":"SX","url":"https://a.example/","content":"from searx"}]}`) + case r.URL.Path == "/res/v1/web/search": // Brave + gotKey, gotQ = r.Header.Get("X-Subscription-Token"), r.URL.Query().Get("q") + fmt.Fprint(w, `{"web":{"results":[{"title":"BR","url":"https://b.example/","description":"from brave"}]}}`) + case r.URL.Path == "/search" && r.Method == http.MethodPost: // Tavily + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + gotKey, gotQ = r.Header.Get("Authorization"), in["query"].(string) + fmt.Fprint(w, `{"results":[{"title":"TV","url":"https://t.example/","content":"from tavily"}]}`) + default: + http.Error(w, "no", 500) + } + })) + defer srv.Close() + braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search" + ctx := context.Background() + + rs, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6) + if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" { + t.Fatalf("searxng: %+v %v", rs, err) + } + rs, err = searchWith(ctx, searchConf{Engine: "brave", Key: "bk"}, "q2", 6) + if err != nil || rs[0].Snippet != "from brave" || gotKey != "bk" || gotQ != "q2" { + t.Fatalf("brave: %+v %v %q", rs, err, gotKey) + } + rs, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6) + if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" { + t.Fatalf("tavily: %+v %v %q", rs, err, gotKey) + } + // The chosen one fails: DuckDuckGo instead (stood in for here). + ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) { + return []searchResult{{Title: "DDG"}}, nil + } + rs, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6) + if err != nil || rs[0].Title != "DDG" { + t.Fatalf("fallback: %+v %v", rs, err) + } +} diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index a87e89a..8e437f7 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -1025,3 +1025,7 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot .fp-ring circle { fill:none; stroke-width:2.6; } .fp-ring .bg { stroke:var(--border); } .fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; } +/* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */ +.ai-form .ai-sub { margin:10px 0 0; font-size:15px; } +.ai-form:not([data-search="searxng"]) .s-url { display:none; } +.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; } diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 55a6259..13191cc 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -155,7 +155,7 @@

ИИ-ассистент

Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.

-
+ +

Поиск в интернете

+

Для плашки «Поиск» в чате. Если выбранный поисковик не ответит, поиск пойдёт через DuckDuckGo.

+ + +