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:
1 parent
9bf30b281e
commit
9912255289
6 files changed
+200
-2
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user