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

+8
View File
@@ -679,6 +679,14 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
_ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model"))) _ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model")))
// The field shows the key there; what it holds is the key (empty: none). // The field shows the key there; what it holds is the key (empty: none).
_ = s.db.SetConfig(cfgAIKey, strings.TrimSpace(r.FormValue("ai_key"))) _ = 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) http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
} }
+1
View File
@@ -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), "InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version, "ServerVersion": Version,
"AI": s.aiConfig(), "AI": s.aiConfig(),
"Search": s.searchConf(),
"DBNow": dbNow, "DBNow": dbNow,
"DBChosen": dbChosen, "DBChosen": dbChosen,
"UfwInstalled": ufwInstalled, "UfwInstalled": ufwInstalled,
+123 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -74,6 +75,127 @@ func parseDDG(page string) []searchResult {
var searchClient = &http.Client{Timeout: 15 * time.Second} 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. // webSearch asks DuckDuckGo for q: up to n results.
func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) { func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
var last 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": "пустой запрос"}) writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
return 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 { if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return return
+52
View File
@@ -1,6 +1,11 @@
package server package server
import ( import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -23,3 +28,50 @@ func TestParseDDGLite(t *testing.T) {
t.Fatalf("first: %+v", r) 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 <strong>brave</strong>"}]}}`)
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)
}
}
+4
View File
@@ -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 circle { fill:none; stroke-width:2.6; }
.fp-ring .bg { stroke:var(--border); } .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; } .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; }
+12 -1
View File
@@ -155,7 +155,7 @@
<h2>ИИ-ассистент</h2> <h2>ИИ-ассистент</h2>
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые <p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.</p> пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.</p>
<form method="post" action="/settings/ai" class="ai-form"> <form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Тип API<select name="ai_type"> <label>Тип API<select name="ai_type">
<option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option> <option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
@@ -166,6 +166,17 @@
<label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label> <label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false"> <label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label> <button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Для плашки «Поиск» в чате. Если выбранный поисковик не ответит, поиск пойдёт через DuckDuckGo.</p>
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
<option value="brave"{{if eq .Search.Engine "brave"}} selected{{end}}>Brave Search API - нужен ключ</option>
<option value="tavily"{{if eq .Search.Engine "tavily"}} selected{{end}}>Tavily - API для ИИ, нужен ключ</option>
</select></label>
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<div><button type="submit">Сохранить</button></div> <div><button type="submit">Сохранить</button></div>
</form> </form>
</div> </div>