From 9bf30b281e6d29eae836d661ea7d1bc80d293cba Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 10:32:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9F=D0=BE=D0=B8=D1=81=D0=BA=20-=20wi?= =?UTF-8?q?th=20it=20on=20the=20assistant=20searches=20DuckDuckGo=20itself?= =?UTF-8?q?=20and=20answers=20from=20what=20it=20found;=20without=20it,=20?= =?UTF-8?q?it=20cannot.=20=C2=AB=D0=94=D1=83=D0=BC=D0=B0=D0=BB=20N=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA.=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/ai.go | 8 + internal/server/search.go | 128 ++++ internal/server/search_test.go | 25 + internal/server/server.go | 1 + internal/server/testdata/ddg_lite.html | 732 ++++++++++++++++++++++ internal/server/web/static/ai.js | 26 +- internal/server/web/static/wsutil.js | 3 +- internal/server/web/static/wsutil.test.js | 5 + 8 files changed, 921 insertions(+), 7 deletions(-) create mode 100644 internal/server/search.go create mode 100644 internal/server/search_test.go create mode 100644 internal/server/testdata/ddg_lite.html diff --git a/internal/server/ai.go b/internal/server/ai.go index bc28745..ce5d31b 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -363,6 +363,10 @@ const aiAgent = ` Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг. Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.` +const aiSearch = ` +Поиск в интернете включён: блок ` + "```search\nзапрос\n```" + ` - панель найдёт его в DuckDuckGo и пришлёт результаты (заголовок, адрес, отрывок) следующим сообщением. +Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. Одно действие за ответ. Ссылки не выдумывай.` + const aiThink = ` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` @@ -524,6 +528,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { Context string `json:"context"` Agent bool `json:"agent"` Think bool `json:"think"` + Search bool `json:"search"` Chat int64 `json:"chat"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "") { @@ -546,6 +551,9 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { if in.Agent { system += aiAgent } + if in.Search { + system += aiSearch + } if in.Think { system += aiThink // Models heed the last words most: the reminder goes there too (not diff --git a/internal/server/search.go b/internal/server/search.go new file mode 100644 index 0000000..c661e15 --- /dev/null +++ b/internal/server/search.go @@ -0,0 +1,128 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +// Web search for the assistant, when the user turned «Поиск» on: DuckDuckGo's +// plain pages, no key and nothing to install. The light page first, the +// HTML one if that fails; DuckDuckGo turns away too many requests from one +// address, and then the assistant is told so. + +type searchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` +} + +var ( + reLiteLink = regexp.MustCompile(`(?s)]+href="([^"]+)"[^>]*class='result-link'[^>]*>(.*?)`) + reLiteSnippet = regexp.MustCompile(`(?s)(.*?)`) + reHTMLLink = regexp.MustCompile(`(?s)]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)`) + reHTMLSnippet = regexp.MustCompile(`(?s)]+class="result__snippet"[^>]*>(.*?)`) + reTags = regexp.MustCompile(`<[^>]+>`) +) + +// text is a bit of a results page as plain text. +func text(s string) string { + return strings.Join(strings.Fields(html.UnescapeString(reTags.ReplaceAllString(s, ""))), " ") +} + +// realURL takes DuckDuckGo's redirect (/l/?uddg=…) back to the address. +func realURL(h string) string { + h = html.UnescapeString(h) + if strings.HasPrefix(h, "//") { + h = "https:" + h + } + if u, err := url.Parse(h); err == nil { + if t := u.Query().Get("uddg"); t != "" { + return t + } + } + return h +} + +// parseDDG reads the results of the light or the HTML page. +func parseDDG(page string) []searchResult { + links, snips := reLiteLink.FindAllStringSubmatch(page, -1), reLiteSnippet.FindAllStringSubmatch(page, -1) + if len(links) == 0 { + links, snips = reHTMLLink.FindAllStringSubmatch(page, -1), reHTMLSnippet.FindAllStringSubmatch(page, -1) + } + var out []searchResult + for i, l := range links { + r := searchResult{Title: text(l[2]), URL: realURL(l[1])} + if i < len(snips) { + r.Snippet = text(snips[i][1]) + } + // Ads lead back through DuckDuckGo itself: left out. + if strings.Contains(r.URL, "duckduckgo.com/y.js") || r.Title == "" { + continue + } + out = append(out, r) + } + return out +} + +var searchClient = &http.Client{Timeout: 15 * time.Second} + +// webSearch asks DuckDuckGo for q: up to n results. +func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) { + var last error + for _, base := range []string{"https://lite.duckduckgo.com/lite/?q=", "https://html.duckduckgo.com/html/?q="} { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+url.QueryEscape(q), nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") + req.Header.Set("Accept-Language", "ru,en;q=0.8") + resp, err := searchClient.Do(req) + if err != nil { + last = err + continue + } + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + resp.Body.Close() + rs := parseDDG(string(b)) + if resp.StatusCode == http.StatusOK && len(rs) > 0 { + if len(rs) > n { + rs = rs[:n] + } + return rs, nil + } + if resp.StatusCode == http.StatusOK { + last = errors.New("ничего не нашлось") + } else { + last = errors.New("поисковик не ответил (" + resp.Status + "), возможно, временно ограничил запросы") + } + } + return nil, last +} + +// POST /api/panel/ai/search - {q}: the results, for a user with the +// assistant on (the «Поиск» switch is the page's: it lets the model ask). +func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" { + writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"}) + return + } + var in struct { + Q string `json:"q"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil || strings.TrimSpace(in.Q) == "" { + writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"}) + return + } + rs, err := webSearch(r.Context(), strings.TrimSpace(in.Q), 6) + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, map[string]any{"ok": true, "data": rs}) +} diff --git a/internal/server/search_test.go b/internal/server/search_test.go new file mode 100644 index 0000000..add841b --- /dev/null +++ b/internal/server/search_test.go @@ -0,0 +1,25 @@ +package server + +import ( + "os" + "strings" + "testing" +) + +// The results of DuckDuckGo's light page: titles, real addresses (not its +// redirects), snippets without tags. +func TestParseDDGLite(t *testing.T) { + b, err := os.ReadFile("testdata/ddg_lite.html") + if err != nil { + t.Fatal(err) + } + rs := parseDDG(string(b)) + if len(rs) < 5 { + t.Fatalf("results: %d", len(rs)) + } + r := rs[0] + if r.URL != "https://go.dev/doc/database/cancel-operations" || !strings.Contains(r.Title, "Canceling in-progress operations") || + !strings.Contains(r.Snippet, "Learn how to use context.Context") || strings.Contains(r.Snippet, "") { + t.Fatalf("first: %+v", r) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a1b9377..613ac3e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -264,6 +264,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/panel/ai/chats/{cid}", s.requireAdmin(s.handleAIHistory)) mux.Handle("POST /api/panel/ai/chats/{cid}/delete", s.requireAdmin(s.handleAIChatDelete)) mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote)) + mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB)) mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart)) diff --git a/internal/server/testdata/ddg_lite.html b/internal/server/testdata/ddg_lite.html new file mode 100644 index 0000000..a0a4a5c --- /dev/null +++ b/internal/server/testdata/ddg_lite.html @@ -0,0 +1,732 @@ + + + + + + + + + + golang context cancel at DuckDuckGo + + + + + + + + + + + + +

 

+
+ DuckDuckGo +
+

 

+ +
+ + + + + + + +
+ + + +
+
+ + +

 

+ + + + + +
+ + + + +
+ + + + + + + + + +      + + + + + + + + +
+ +
+ + + + +

 

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 1.  + + + Canceling in-progress operations - The Go Programming Language +
    + Learn how to use context.Context to manage and cancel database operations in Go. See examples of how to set a timeout, deadline, and propagate cancellation requests with context. +
    + go.dev/doc/database/cancel-operations + +
  
+ + 2.  + + + Go Context Tutorial: Cancellation and Deadlines +
    + Learn Go context with practical cancellation, timeout, HTTP request, and goroutine examples. +
    + golangtutorial.com/golang-context/ + +     + 2026-07-29T00:00:00.0000000 + +
  
+ + 3.  + + + go - Context confusion regarding cancellation - Stack Overflow +
    + Additional info #1: calling cancel () right after context initialized As per context documentation regarding the cancel () function: Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete. It's good to always call cancel () function right after the context declaration. doesn't matter whether it's also ... +
    + stackoverflow.com/questions/52799280/context-confusion-regarding-cancellation + +
  
+ + 4.  + + + context package - context - Go Packages +
    + Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes. +
    + pkg.go.dev/context + +     + 2026-10-08T00:00:00.0000000 + +
  
+ + 5.  + + + Best practices on go context cancelation functions +
    + So, is passing this cancellation function actually an issue? Are there any best practices related to the use of the context package and their cancel function? +
    + stackoverflow.com/questions/70042646/best-practices-on-go-context-cancelation-functions + +
  
+ + 6.  + + + Golang Context - Cancellation, Timeout and Propagation | golangbot.com +
    + Learn how to cancel long running tasks in Go using context cancellation and timeout. This tutorial also teaches how to terminate outstanding web requests which were cancelled by the client. +
    + golangbot.com/context-timeout-cancellation/ + +     + 2026-07-08T00:00:00.0000000 + +
  
+ + 7.  + + + Go Concurrency Patterns: Context - The Go Programming Language +
    + Learn how to use the context package to pass request-scoped values, cancellation signals, and deadlines across API boundaries in Go servers. See a complete example of an HTTP server that handles Google Web Search requests with context. +
    + go.dev/blog/context + +
  
+ + 8.  + + + How to Use Context in Golang (Deadlines, Cancellation, and Passing Values) +
    + In this post, we'll learn about Go's context package, and more specifically, how we can use it to improve our applications performance. The main purpose of using Context is to manage long-running processes (like an HTTP request, a database call, or a network call) in a way that doesn't waste resources. If used correctly, the context package can help you: Cancel long running processes ... +
    + www.sohamkamani.com/golang/context/ + +
  
+ + 9.  + + + context - The Go Programming Language +
    + The returned context's Done channel is closed when the deadline expires, when the returned cancel function is called, or when the parent context's Done channel is closed, whichever happens first. +
    + golangdoc.github.io/pkg/1.12/context/index.html + +
  
+ + 10.  + + + Golang context — WithCancel, WithTimeout, WithDeadline, Background +
    + The golang context package ties cancellation, timeouts, and optional request values to a Context value you pass through your call stack. Use Background (or TODO temporarily) as the root, WithTimeout / WithDeadline for time bounds, WithCancel for explicit shutdown, and WithValue only for small, request-scoped metadata—always defer cancel ... +
    + www.golinuxcloud.com/golang-context/ + +
  
+ + + +
+ + + + + + + + + +      + + + + + + + + +
+ + +
+ +

 

+ +
+ + + + + + + + + +
+ +

 

+ + + + +
 
+ + + \ No newline at end of file diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index c11986d..3c4bc12 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -28,6 +28,8 @@ '
' + '' + + '' + '' + '
' + @@ -314,6 +316,14 @@ const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); }; // perform does one action with the user's rights and tells what came of it. async function perform(a, signal) { + // A web search: through the server, only with «Поиск» on. + if (a.kind === 'search') { + if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»'; + const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', + body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'})); + if (!r.ok) return 'Ошибка поиска «' + a.body + '»: ' + r.error; + return 'Результаты поиска «' + a.body + '»:\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n'); + } await loadMachines(); const m = machineOf(a.machine); if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', '); @@ -355,11 +365,12 @@ // ---------- messages ---------- // stripThink: the answer without its thinking - what is acted on and kept. const stripThink = t => t.replace(/^\s*(?:|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); - const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись'}; + const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск'}; const IC = d => '' + d + ''; const ICONS = {run: IC(''), read: IC(''), - ls: IC(''), write: IC('')}; - const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать'}; + ls: IC(''), write: IC(''), + search: IC('')}; + const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти'}; // render: text with ``` blocks; action blocks get their button. // render draws an answer: its thinking, folded, above; the text with // Markdown; ``` blocks apart, action blocks with their button. The @@ -384,7 +395,7 @@ think.classList.toggle('going', going); // How long: timed here as it streams, or kept with the answer. const ms = el._thinkMs || (el._t1 - el._t0); - think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' с'; + think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.'; think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim()); text = text.slice(th[0].length); } @@ -459,7 +470,7 @@ await loadMachines(); const r = await fetch('/api/panel/ai/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal, - body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), chat: chatId}) + body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId}) }); // A new chat gets its id (and its name) from the answer. const cid = Number(r.headers.get('X-Chat-Id')) || 0; @@ -489,6 +500,7 @@ const pill = k => q('.ai-pill[data-mode="' + k + '"]'); const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true'; const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true'; + const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true'; // The modes are kept in this browser. box.querySelectorAll('.ai-pill').forEach(b => { try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ } @@ -503,9 +515,11 @@ // and its result sent back - until an answer asks for nothing. async function talkTurn(text) { let turn = await ask(text); - for (let step = 0; agentOn() && ac && !ac.signal.aborted && step < 15; step++) { + for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) { const a = WSUtil.parseActions(turn.answer)[0]; if (!a) break; + // Without «Агент» only searches go on by themselves, with «Поиск» on. + if (!agentOn() && !(a.kind === 'search' && searchOn())) break; const b = turn.el.querySelector('.ai-act'); if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) { if (b) { b.disabled = true; b.textContent = 'Отклонено'; } diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js index 4eaa4ef..ed6d90e 100644 --- a/internal/server/web/static/wsutil.js +++ b/internal/server/web/static/wsutil.js @@ -48,10 +48,11 @@ // closed ``` blocks headed run [machine], read|ls [machine:]path or // write [machine:]path (its body the file's text). function parseActions(text) { - const out = [], re = /```(run|read|ls|write)([^\n]*)\n([\s\S]*?)```/g; + const out = [], re = /```(run|read|ls|write|search)([^\n]*)\n([\s\S]*?)```/g; for (let m; (m = re.exec(text));) { const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, ''); if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; } + if (kind === 'search') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; } const c = head.indexOf(':'); const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : ''; out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''}); diff --git a/internal/server/web/static/wsutil.test.js b/internal/server/web/static/wsutil.test.js index 744473a..1b616ba 100644 --- a/internal/server/web/static/wsutil.test.js +++ b/internal/server/web/static/wsutil.test.js @@ -75,3 +75,8 @@ test('md: the common marks, everything else escaped', () => { // Inside code nothing is marked up. assert.strictEqual(U.md('`**a**`'), '

**a**

'); }); + +test('parseActions: search blocks', () => { + assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'), + [{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]); +});