From ae8fffe7d85571c8be3297186fde35eaeee3fe55 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 17:32:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20fetch=20for=20the=20assistant=20with=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=B8=D1=81=D0=BA=20on=20-=20a=20page's=20main=20t?= =?UTF-8?q?ext=20for=20the=20model,=20the=20server's=20own=20network=20out?= =?UTF-8?q?=20of=20reach;=20shorter=20words=20in=20=D0=9D=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20and=20the=20chat;=20the?= =?UTF-8?q?=20+=20menu's=20roots=20a=20step=20left.=20Also=20files=20from?= =?UTF-8?q?=20panes=20into=20the=20chat,=20docview=20and=20drag=20fixes=20?= =?UTF-8?q?(by=20the=20user)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/ai.go | 10 +- internal/server/ai_test.go | 35 +++- internal/server/fetch.go | 199 ++++++++++++++++++++ internal/server/fetch_test.go | 78 ++++++++ internal/server/search.go | 1 + internal/server/server.go | 1 + internal/server/web/static/ai.js | 179 +++++++++++++----- internal/server/web/static/app.css | 50 +++-- internal/server/web/static/dnd.js | 18 ++ internal/server/web/static/docview.js | 149 ++++++++++++++- internal/server/web/static/docview.test.js | 32 ++++ internal/server/web/static/filepane.js | 9 +- internal/server/web/static/fileviews.js | 2 +- internal/server/web/static/workspace.js | 31 ++- internal/server/web/static/wsutil.js | 4 +- internal/server/web/static/wsutil.test.js | 5 + internal/server/web/templates/settings.html | 11 +- 17 files changed, 712 insertions(+), 102 deletions(-) create mode 100644 internal/server/fetch.go create mode 100644 internal/server/fetch_test.go create mode 100644 internal/server/web/static/docview.test.js diff --git a/internal/server/ai.go b/internal/server/ai.go index 963c305..56a5792 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -36,8 +36,8 @@ const ( DefaultAIURL = "https://agentproxy.minomix.ru" ) -// aiUserKey is the config key of a user's own switch: the assistant is off -// for everyone until they turn it on in their profile. +// aiUserKey is the config key of a user's own switch. Missing means on by +// default; an explicit "0" means the user turned it off. func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) } type aiConfig struct{ Type, URL, Key, Model string } @@ -634,7 +634,7 @@ func (s *Server) aiAdopt(owner int64) { // text with "[ошибка: …]". func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { a := s.currentAdmin(r) - if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" { + if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on == "0" { http.Error(w, "ассистент выключен в профиле", http.StatusForbidden) return } @@ -866,7 +866,7 @@ func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) { // GET /api/panel/ai - whether the assistant is on for this user. func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) { on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID)) - writeJSON(w, map[string]any{"on": on == "1"}) + writeJSON(w, map[string]any{"on": on != "0"}) } // POST /api/panel/ai/toggle - {on}: the user's own switch. @@ -878,7 +878,7 @@ func (s *Server) handleAIToggle(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) return } - v := "" + v := "0" if in.On { v = "1" } diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index 7d716e5..0d95b36 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -154,8 +154,8 @@ func TestAIAnthropic(t *testing.T) { } } -// The chat route: only for a user who turned the assistant on. -func TestAIChatNeedsUserOn(t *testing.T) { +// The chat is on by default, and an explicit user preference can still turn it off. +func TestAIChatDefaultOnAndCanBeDisabled(t *testing.T) { s, c := newTestServer(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n") @@ -170,11 +170,17 @@ func TestAIChatNeedsUserOn(t *testing.T) { s.Handler().ServeHTTP(w, req) return w } - if w := post(); w.Code != http.StatusForbidden { - t.Fatalf("off: %d %s", w.Code, w.Body.String()) - } a, _ := s.db.AdminByUsername("adm") - _ = s.db.SetConfig(aiUserKey(a.ID), "1") + state := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil) + req.AddCookie(c) + s.handleAIState(state, req) + var got struct { + On bool `json:"on"` + } + if err := json.Unmarshal(state.Body.Bytes(), &got); err != nil || !got.On { + t.Fatalf("default state: %s (%v)", state.Body.String(), err) + } w := post() b, _ := io.ReadAll(w.Body) if w.Code != 200 || string(b) != "ok" { @@ -189,6 +195,23 @@ func TestAIChatNeedsUserOn(t *testing.T) { if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" { t.Fatalf("history: %+v", h) } + off := httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/panel/ai/toggle", strings.NewReader(`{"on":false}`)) + req.AddCookie(c) + s.handleAIToggle(off, req) + state = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil) + req.AddCookie(c) + s.handleAIState(state, req) + var after struct { + On bool `json:"on"` + } + if err := json.Unmarshal(state.Body.Bytes(), &after); err != nil || after.On { + t.Fatalf("disabled state: %s (%v)", state.Body.String(), err) + } + if w := post(); w.Code != http.StatusForbidden { + t.Fatalf("explicit off: %d %s", w.Code, w.Body.String()) + } } func TestAIChatKeepsAttachmentTextOnly(t *testing.T) { diff --git a/internal/server/fetch.go b/internal/server/fetch.go new file mode 100644 index 0000000..f21d7dd --- /dev/null +++ b/internal/server/fetch.go @@ -0,0 +1,199 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "html" + "io" + "mime" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// fetch: one page by its address, for the assistant with «Поиск» on - to +// read what a search found. The page comes back as the model wants it: its +// title and main text, headings and lists kept, scripts, styles and menus +// gone, cut to fetchMaxChars. Only http(s), and never the server's own +// network: loopback, private and link-local addresses are refused at the +// moment of connecting, redirects included. + +const fetchMaxChars = 12000 + +// fetchAllowPrivate lets tests reach their own server. +var fetchAllowPrivate = false + +type fetchedPage struct { + URL string `json:"url"` + Title string `json:"title"` + Type string `json:"type"` + Text string `json:"text"` + Truncated bool `json:"truncated"` +} + +func privateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() +} + +var fetchClient = &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if privateIP(ip) && !fetchAllowPrivate { + continue + } + var d net.Dialer + return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + return nil, errors.New("адрес во внутренней сети - не открываю") + }, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("слишком много переадресаций") + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return errors.New("переадресация не на http(s)") + } + return nil + }, +} + +func fetchPage(ctx context.Context, raw string) (fetchedPage, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fetchedPage{}, errors.New("нужен адрес http(s)://…") + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), 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", "text/html,text/plain,application/json;q=0.9,*/*;q=0.5") + req.Header.Set("Accept-Language", "ru,en;q=0.8") + resp, err := fetchClient.Do(req) + if err != nil { + return fetchedPage{}, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fetchedPage{}, errors.New("страница ответила " + resp.Status) + } + ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + p := fetchedPage{URL: resp.Request.URL.String(), Type: ct} + b, _ := io.ReadAll(io.LimitReader(resp.Body, 3<<20)) + switch { + case ct == "text/html" || ct == "application/xhtml+xml" || (ct == "" && strings.Contains(strings.ToLower(string(b[:min(len(b), 512)])), " fetchMaxChars { + p.Text, p.Truncated = string(r[:fetchMaxChars]), true + } + return p, nil +} + +var ( + reTitle = regexp.MustCompile(`(?is)]*>(.*?)`) + reDrop = regexp.MustCompile(`(?is)<(script|style|noscript|svg|nav|footer|header|aside|form|iframe|template|button|select)\b[^>]*>.*?`) + reComment = regexp.MustCompile(`(?s)`) + reMain = regexp.MustCompile(`(?is)<(main|article)\b[^>]*>(.*)`) + reHead = regexp.MustCompile(`(?is)]*>`) + reLi = regexp.MustCompile(`(?is)]*>`) + reBlock = regexp.MustCompile(`(?is)]*>`) + reCell = regexp.MustCompile(`(?is)`) + reSpaces = regexp.MustCompile(`[ \t\x{00a0}]+`) + reBlanks = regexp.MustCompile(`\n{3,}`) + rePre = regexp.MustCompile(`(?is)]*>.*?`) + rePreMark = regexp.MustCompile("\x00pre[0-9]+\x00") + reWS = regexp.MustCompile(`\s+`) +) + +// pageText is a page's title and its text, in Markdown-ish lines. +func pageText(page string) (string, string) { + title := "" + if m := reTitle.FindStringSubmatch(page); m != nil { + title = strings.TrimSpace(html.UnescapeString(reTags.ReplaceAllString(m[1], ""))) + } + s := reComment.ReplaceAllString(page, "") + s = reDrop.ReplaceAllString(s, " ") + // Line breaks of the source mean nothing outside
: one space. The
+	// 
 blocks keep theirs (held aside meanwhile).
+	var pres []string
+	s = rePre.ReplaceAllStringFunc(s, func(p string) string {
+		pres = append(pres, p)
+		return "\x00pre" + strconv.Itoa(len(pres)-1) + "\x00"
+	})
+	s = reWS.ReplaceAllString(s, " ")
+	s = rePreMark.ReplaceAllStringFunc(s, func(m string) string {
+		i, _ := strconv.Atoi(strings.Trim(m, "\x00pre"))
+		return pres[i]
+	})
+	// The page's main part, when it marks one.
+	if m := reMain.FindStringSubmatch(s); m != nil && len(m[2]) > 200 {
+		s = m[2]
+	}
+	s = reHead.ReplaceAllStringFunc(s, func(t string) string {
+		n, _ := strconv.Atoi(reHead.FindStringSubmatch(t)[1])
+		return "\n\n" + strings.Repeat("#", n) + " "
+	})
+	s = reLi.ReplaceAllString(s, "\n- ")
+	s = reCell.ReplaceAllString(s, " | ")
+	s = reBlock.ReplaceAllString(s, "\n")
+	s = html.UnescapeString(reTags.ReplaceAllString(s, ""))
+	var lines []string
+	for _, l := range strings.Split(s, "\n") {
+		l = strings.TrimSpace(reSpaces.ReplaceAllString(l, " "))
+		if l == "-" || l == "|" {
+			continue
+		}
+		lines = append(lines, l)
+	}
+	text := reBlanks.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
+	return title, strings.TrimSpace(text)
+}
+
+// POST /api/panel/ai/fetch - {url}: the page as text, for a user with the
+// assistant on.
+func (s *Server) handleAIFetch(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 {
+		URL string `json:"url"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil {
+		writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
+		return
+	}
+	p, err := fetchPage(r.Context(), in.URL)
+	if err != nil {
+		writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+		return
+	}
+	writeJSON(w, map[string]any{"ok": true, "data": p})
+}
diff --git a/internal/server/fetch_test.go b/internal/server/fetch_test.go
new file mode 100644
index 0000000..032d23b
--- /dev/null
+++ b/internal/server/fetch_test.go
@@ -0,0 +1,78 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// A page as the model wants it: title, the main text with headings and
+// lists, no scripts, styles or menus.
+func TestPageText(t *testing.T) {
+	page := `Go 1.24 & more
+
+
+

Go 1.24

Generic type aliases are here.

+
  • Swiss tables
  • weak pointers
© Google
` + title, text := pageText(page) + if title != "Go 1.24 & more" { + t.Fatalf("title %q", title) + } + for _, want := range []string{"# Go 1.24", "Generic type aliases are here.", "- Swiss tables", "- weak pointers"} { + if !strings.Contains(text, want) { + t.Fatalf("no %q in:\n%s", want, text) + } + } + for _, not := range []string{"track()", "color:red", "Home", "© Google"} { + if strings.Contains(text, not) { + t.Fatalf("%q left in:\n%s", not, text) + } + } +} + +// The server's own network is out of reach: no loopback, private or local +// addresses, whatever the name says. +func TestFetchRefusesPrivate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "secret") })) + defer srv.Close() + for _, u := range []string{srv.URL, "http://localhost:1/", "http://10.0.0.1/", "http://169.254.169.254/latest/meta-data/", "file:///etc/passwd", "ftp://x/"} { + if _, err := fetchPage(context.Background(), u); err == nil { + t.Errorf("%s: fetched", u) + } + } +} + +func TestFetchPage(t *testing.T) { + old := fetchAllowPrivate + fetchAllowPrivate = true + defer func() { fetchAllowPrivate = old }() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/json" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"a":1}`) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, "T

"+strings.Repeat("слово ", 5000)+"

") + })) + defer srv.Close() + p, err := fetchPage(context.Background(), srv.URL+"/page") + if err != nil || p.Title != "T" || !p.Truncated || len([]rune(p.Text)) > fetchMaxChars+10 { + t.Fatalf("page: %v %q %v %d", err, p.Title, p.Truncated, len(p.Text)) + } + p, err = fetchPage(context.Background(), srv.URL+"/json") + if err != nil || p.Text != `{"a":1}` { + t.Fatalf("json: %+v %v", p, err) + } +} + +// Lines broken in the page's source are one paragraph; code keeps its lines. +func TestPageTextJoinsWrapped(t *testing.T) { + _, text := pageText("

The latest Go release,\n version 1.24,\narrives in February.

a\nb
") + if !strings.Contains(text, "The latest Go release, version 1.24, arrives in February.") || !strings.Contains(text, "a\nb") { + t.Fatalf("%q", text) + } +} diff --git a/internal/server/search.go b/internal/server/search.go index 62d4f41..9dc6c39 100644 --- a/internal/server/search.go +++ b/internal/server/search.go @@ -150,6 +150,7 @@ func searchPrompt(c searchConf) string { return "\nПоиск в интернете включён (поисковик: " + engineName(c.Engine) + "): блок ```search\nзапрос\n``` - панель найдёт и пришлёт результаты " + "(заголовок, адрес, отрывок) следующим сообщением, с пометкой, через какой поисковик они пришли.\n" + "Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. " + + "Чтобы прочитать найденную страницу целиком (или адрес, который дал пользователь), - блок ```fetch\nhttps://адрес\n```: придёт её текст. " + "Одно действие за ответ. Ссылки не выдумывай." } diff --git a/internal/server/server.go b/internal/server/server.go index 613ac3e..1bb57a4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -265,6 +265,7 @@ func (s *Server) Handler() http.Handler { 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 /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch)) 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/web/static/ai.js b/internal/server/web/static/ai.js index c27c2dc..c3dd899 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -3,10 +3,10 @@ // text of its terminal or its list of files - and the answer streams in. // Commands the model suggests come as ```run``` blocks with «Выполнить»: a // click sends the command to the active terminal, with the user's rights. -// Off until the user turns it on in the profile. +// On by default; the user can turn it off in the profile. (function () { 'use strict'; - const PLANE = ''; + const PLANE = ''; const STOP = ''; const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); const fab = document.createElement('button'); @@ -24,7 +24,7 @@ '' + '' + '
' + - '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.

' + + '

Спросите о том, что открыто в панели.

' + '
' + '
' + '' + '
' + '' + - '
' + + '' + '
' + '' + - '' + '' + '
'; document.body.append(fab, box); @@ -46,6 +45,7 @@ const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'), jump = q('.ai-jump'); const attachInput = q('.ai-file-input'), attachList = q('.ai-attachments'), filePicker = q('.ai-file-picker'); let attachments = []; + const importingPaneFiles = new Set(); const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80; function updateJump() { jump.hidden = nearBottom() || !isOpen(); } list.addEventListener('scroll', updateJump, {passive: true}); @@ -337,6 +337,15 @@ 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 page by its address: its text, through the server, with «Поиск» on. + if (a.kind === 'fetch') { + if (!searchOn()) return 'Ошибка: чтение страниц выключено - включите плашку «Поиск»'; + const r = await fetch('/api/panel/ai/fetch', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', + body: JSON.stringify({url: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'})); + if (!r.ok) return 'Ошибка чтения ' + a.body + ': ' + r.error; + const p = r.data; + return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text; + } // A web search: through the server, only with «Поиск» on. if (a.kind === 'search') { if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»'; @@ -391,12 +400,13 @@ // ---------- 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: 'запись', search: 'поиск'}; + const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'}; const IC = d => '' + d + ''; const ICONS = {run: IC(''), read: IC(''), ls: IC(''), write: IC(''), - search: IC('')}; - const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти'}; + search: IC(''), + fetch: IC('')}; + const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'}; // 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 @@ -477,9 +487,10 @@ async function makeAttachments(file, name = file.name) { const ext = fileExt(name), type = (file.type || '').toLowerCase(); if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл'); - if (officeExt.test(ext) || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) { + if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) { const buf = await file.arrayBuffer(); let text; if (ext === 'docx') text = previewText(await DocView.docx(buf)); + else if (ext === 'pdf') text = await DocView.pdf(buf); else if (ext === 'xlsx') text = previewText(await DocView.xlsx(buf)); else if (ext === 'ods') text = previewText(await DocView.ods(buf)); else if (ext === 'odt') text = previewText(await DocView.odt(buf)); @@ -494,28 +505,88 @@ if (!/^image\//.test(mime) && !/^audio\//.test(mime) && !/^video\//.test(mime)) throw new Error(name + ': поддерживаются изображения, аудио, видео, текст и документы Word/Excel'); return [{name, mime, data: bytesToBase64(await file.arrayBuffer()), size: file.size}]; } - async function addFiles(files) { - for (const file of files) { + async function addFiles(files, sourceIdentities = []) { + for (let i = 0; i < files.length; i++) { + const file = files[i], sourceIdentity = sourceIdentities[i]; if (attachments.length >= MAX_ATTACHMENTS) { setFileError('Можно прикрепить не больше ' + MAX_ATTACHMENTS + ' файлов или кадров.'); break; } try { const ready = await makeAttachments(file); const total = attachments.reduce((n, a) => n + (a.size || 0), 0) + ready.reduce((n, a) => n + (a.size || 0), 0); if (attachments.length + ready.length > MAX_ATTACHMENTS) throw new Error('Слишком много вложений (максимум ' + MAX_ATTACHMENTS + ').'); if (total > MAX_TOTAL) throw new Error('Общий размер вложений не должен превышать 16 МиБ.'); + if (sourceIdentity) ready.forEach(attachment => Object.defineProperty(attachment, 'sourceIdentity', {value: sourceIdentity})); attachments.push(...ready); renderAttachments(); } catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); } } } - function add(role, text, thinkMs) { + const FILE_KIND = file => { + const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase(); + if (mime.startsWith('image/')) return ['image', 'Изображение']; + if (mime.startsWith('audio/')) return ['audio', 'Аудио']; + if (mime.startsWith('video/')) return ['video', 'Видео']; + if (ext === 'pdf') return ['pdf', 'PDF-документ']; + if (/^(doc|docx|odt|xls|xlsx|ods)$/.test(ext)) return ['document', 'Документ']; + return ['text', 'Текстовый файл']; + }; + const FILE_ICONS = { + image: '', + audio: '', + video: '', + pdf: '', + document: '', + text: '' + }; + function renderFileCards(host, files) { + if (!files.length) return; + const group = document.createElement('div'); group.className = 'ai-history-files'; + files.forEach(file => { + const [kind, label] = FILE_KIND(file); + const card = document.createElement('div'); card.className = 'ai-history-file ' + kind; + const icon = document.createElement('span'); icon.className = 'ai-history-file-icon'; + icon.innerHTML = '' + FILE_ICONS[kind] + ''; + const info = document.createElement('span'); info.className = 'ai-history-file-info'; + const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name; + const meta = document.createElement('span'); meta.className = 'ai-history-file-meta'; + const ext = fileExt(file.name).toUpperCase(); + meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : ''); + info.append(name, meta); card.append(icon, info); group.appendChild(card); + }); + host.appendChild(group); + } + function splitStoredAttachments(text) { + const re = /(^|\n\n)\[Вложение: ([^\r\n]+)\]/g, marks = [...text.matchAll(re)]; + if (!marks.length) return {text, files: []}; + const files = []; + marks.forEach((mark, i) => { + const start = mark.index + mark[0].length; + const end = i + 1 < marks.length ? marks[i + 1].index : text.length; + const tail = text.slice(start, end).replace(/^\n+|\n+$/g, ''); + const content = /^Содержимое файла:\n([\s\S]*)$/.exec(tail); + files.push({name: mark[2], text: content ? content[1] : ''}); + }); + return {text: text.slice(0, marks[0].index).trimEnd(), files}; + } + function add(role, text, thinkMs, files) { q('.ai-hello').hidden = true; const el = document.createElement('div'); el.className = 'ai-msg ' + role; - if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); } else el.textContent = text; + if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); } + else if (role === 'user') { + const parsed = splitStoredAttachments(text), shownFiles = files && files.length ? files : parsed.files; + const message = files && files.length ? text : parsed.text; + if (message) { const copy = document.createElement('div'); copy.className = 'ai-user-copy'; copy.textContent = message; el.appendChild(copy); } + renderFileCards(el, shownFiles); + } else el.textContent = text; const follow = nearBottom(); list.appendChild(el); scrollIfFollowing(follow); return el; } + function formatFileSize(n) { + if (n < 1024) return n + ' Б'; + if (n < 1048576) return (n / 1024).toFixed(1) + ' КБ'; + return (n / 1048576).toFixed(1) + ' МБ'; + } // An action's result shows folded; it reaches the talk on the server with // the next question (or by note, when done by hand). function showResult(text) { @@ -547,8 +618,7 @@ // ask sends the new words (or an action's result) and shows the answer. async function ask(text, res, files) { if (text || (files && files.length)) { - const labels = files && files.length ? '\n\n📎 ' + files.map(f => f.name).join(', ') : ''; - add('user', (text || '') + labels); + add('user', text || '', 0, files || []); } if (res) showResult(res); const el = add('assistant', ''); @@ -582,17 +652,15 @@ if (done) break; answer += dec.decode(value, {stream: true}); el.classList.remove('wait'); - const follow = nearBottom(); render(el, answer, false); - scrollIfFollowing(follow); + updateJump(); } } catch (e) { if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']'; } finally { el.classList.remove('wait'); - const follow = nearBottom(); render(el, answer || '[нет ответа]', true); - scrollIfFollowing(follow); + updateJump(); } return {el, answer: stripThink(answer)}; } @@ -618,7 +686,7 @@ 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; + if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break; const b = turn.el.querySelector('.ai-act'); if (needsYes(a) && !await confirmAction(a)) { if (b) { b.disabled = true; b.textContent = 'Отклонено'; } @@ -634,10 +702,9 @@ ac = null; send.innerHTML = PLANE; send.title = 'Отправить'; } - const form = q('.ai-form-in'), attachBtn = q('.ai-attach'), paneBtn = q('.ai-from-pane'); + const form = q('.ai-form-in'), attachBtn = q('.ai-attach'); attachBtn.onclick = () => attachInput.click(); attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; }; - paneBtn.onclick = () => showPaneFiles(); form.onsubmit = ev => { ev.preventDefault(); if (ac) { ac.abort(); return; } @@ -650,36 +717,52 @@ filePicker.hidden = true; talkTurn(text, files); }; - box.addEventListener('dragover', ev => { if (ev.dataTransfer && [...ev.dataTransfer.types].includes('Files')) { ev.preventDefault(); form.classList.add('drag'); } }); + box.addEventListener('dragover', ev => { + if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return; + ev.preventDefault(); + form.classList.toggle('drag', isDropTargetAt(ev.clientX, ev.clientY)); + }); box.addEventListener('dragleave', ev => { if (!box.contains(ev.relatedTarget)) form.classList.remove('drag'); }); box.addEventListener('drop', ev => { - if (![...ev.dataTransfer.types].includes('Files')) return; - ev.preventDefault(); form.classList.remove('drag'); addFiles([...ev.dataTransfer.files]); + if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return; + ev.preventDefault(); form.classList.remove('drag'); + if (isDropTargetAt(ev.clientX, ev.clientY)) addFiles([...ev.dataTransfer.files]); }); - async function showPaneFiles() { - filePicker.replaceChildren(); - const pane = window.Workspace && Workspace.activePane(); - if (!pane || !/files$/.test(pane.kind)) { setFileError('Сначала откройте файловое окно подключённой машины.'); return; } - const files = (pane.entries || []).filter(e => !e.is_dir); - if (!files.length) { setFileError('В текущей папке нет файлов.'); return; } - const title = document.createElement('strong'); title.textContent = 'Файлы · ' + pane.cwd; filePicker.appendChild(title); - files.forEach(entry => { - const b = document.createElement('button'); b.type = 'button'; b.className = 'ai-file-option'; b.textContent = entry.name; - b.onclick = async () => { - b.disabled = true; b.textContent += ' · загрузка…'; - try { - const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + entry.name; - const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'}); - if (!response.ok) throw new Error('Не удалось скачать файл (' + response.status + ')'); - const blob = await response.blob(); - await addFiles([new File([blob], entry.name, {type: blob.type})]); - filePicker.hidden = true; - } catch (e) { setFileError(e.message || 'Не удалось прочитать файл машины'); } - }; - filePicker.appendChild(b); - }); - filePicker.hidden = false; + async function addPaneFiles(pane, names) { + if (!pane || !/files$/.test(pane.kind)) return; + const entries = new Map((pane.entries || []).map(entry => [entry.name, entry])); + const errors = []; + for (const name of names) { + const entry = entries.get(name); + if (!entry) { errors.push(name + ': элемент больше не отображается'); continue; } + if (entry.is_dir) { errors.push(name + ': папки прикрепить нельзя'); continue; } + const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + name; + const identity = JSON.stringify([pane.source.id, path]); + if (importingPaneFiles.has(identity) || attachments.some(file => file.sourceIdentity === identity)) { + errors.push(name + ': этот файл уже прикреплён'); + continue; + } + importingPaneFiles.add(identity); + try { + const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'}); + if (!response.ok) throw new Error('HTTP ' + response.status); + const blob = await response.blob(); + await addFiles([new File([blob], name, {type: blob.type})], [identity]); + } catch (e) { errors.push(name + ': ' + (e.message || 'не удалось скачать')); } + finally { importingPaneFiles.delete(identity); } + } + if (errors.length) setFileError(errors.join('; ')); } + function isDropTargetAt(x, y) { + if (!isOpen()) return false; + const r = q('.ai-inputbox').getBoundingClientRect(); + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + } + window.AIChat = { + isDropTargetAt, + setDragging(on) { form.classList.toggle('drag', !!on); }, + dropFiles: addPaneFiles + }; // The field grows with the text, up to a few lines. function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; } input.addEventListener('input', grow); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index d4681ef..332c5a4 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -363,7 +363,7 @@ body.split1 .float-add { top:42px; right:16px; } body.multi .float-add { opacity:0; pointer-events:none; } .float-add { transition:opacity .25s ease, background .25s ease, top .3s ease, right .3s ease, width .3s ease, height .3s ease, border-radius .3s ease; } /* In a file pane's top bar: a button like its neighbours. */ -.float-add.in-files { width:34px; height:34px; font-size:19px; opacity:1; border-radius:8px; } +.float-add.in-files { position:static; z-index:auto; width:34px; height:34px; font-size:19px; opacity:1; border-radius:8px; } .float-add.in-files:hover { background:#151a21; } .tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995); transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; } @@ -400,6 +400,8 @@ body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; } box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; } .drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); } body.dnd, body.dnd * { cursor:grabbing !important; } +.tab, .tab *, .pane-head, .pane-head *, .ai-head, .ai-head *, .ai-fab, +.fp-list tr, .fp-list tr * { -webkit-user-drag:none; } /* Files dragged out of a pane: the target's outline, and the small window where they were dropped. */ .hint.drophint { position:fixed; z-index:55; transition:none; } @@ -814,9 +816,7 @@ p.mv-a { margin:0; font-size:13px; } /* The pane: its bar and list scroll inside it. */ .fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; } .tabview:not(.split) .fpane-body { padding:12px 16px 16px; } -/* The file pane under the corner "+" keeps its first bar clear of it, - while the "+" is there (one tab; with the tab strip it is hidden). */ -body:not(.multi) .fpane.under-plus .fm-bar:first-child { margin-right:40px; } + /* The bars' square buttons, as tall as the path box beside them. */ .fm-bar > .btn.sq, .fp-sel .btn.sq { width:34px; height:34px; } @@ -863,8 +863,20 @@ body.touch.term-active .ai-fab { bottom:104px; } .ai-jump:hover { background:#202833; } .ai-jump[hidden] { display:none; } .ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; } -.ai-msg.user { align-self:flex-end; background:#16324f; white-space:pre-wrap; } +.ai-msg.user { align-self:flex-end; background:#16324f; white-space:normal; } +.ai-user-copy { white-space:pre-wrap; } .ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); } +.ai-history-files { display:grid; gap:6px; margin-top:8px; } +.ai-history-file { display:flex; align-items:center; gap:9px; min-width:0; padding:8px 10px; border:1px solid rgba(255,255,255,.12); border-radius:10px; background:rgba(8,13,20,.32); } +.ai-history-file-icon { flex:none; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:9px; color:#a9c1ff; background:rgba(91,140,255,.14); } +.ai-history-file.audio .ai-history-file-icon { color:#d1aaff; background:rgba(139,92,246,.16); } +.ai-history-file.video .ai-history-file-icon { color:#ffb4a2; background:rgba(249,115,85,.14); } +.ai-history-file.pdf .ai-history-file-icon { color:#ffaaa5; background:rgba(239,68,68,.14); } +.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); } +.ai-history-file-info { display:grid; gap:2px; min-width:0; } +.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; } +.ai-history-file-meta { color:var(--muted); font-size:10.5px; } + /* The model starting: three dots rising in turn. */ .ai-typing { display:inline-flex; gap:4px; padding:4px 2px; } .ai-typing i { width:6px; height:6px; border-radius:50%; background:var(--muted); animation:ai-typing 1.2s ease-in-out infinite; } @@ -874,13 +886,13 @@ body.touch.term-active .ai-fab { bottom:104px; } .ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; } .ai-code .ai-run { margin-top:6px; } .ai-form-in { display:flex; padding:8px 10px 10px; } -.ai-inputbox { min-width:0; flex:1; padding:5px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s; } +.ai-inputbox { position:relative; min-width:0; flex:1; padding:5px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s, background-color .15s; } .ai-inputbox:focus-within { border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); } -.ai-entry { display:flex; align-items:flex-end; gap:5px; min-width:0; } -.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 6px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; } +.ai-entry { display:flex; align-items:center; gap:5px; min-width:0; } +.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:16px; line-height:24px; padding:8px 6px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; } .ai-form-in textarea:focus { outline:none; border-color:transparent; } -.ai-attach, .ai-from-pane { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; } -.ai-attach:hover, .ai-from-pane:hover { color:var(--text); background:#1b222c; } +.ai-attach { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; } +.ai-attach:hover { color:var(--text); background:#1b222c; } .ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; } .ai-attachments[hidden] { display:none; } .ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; } @@ -889,16 +901,15 @@ body.touch.term-active .ai-fab { bottom:104px; } .ai-file-chip button:hover { color:var(--text); background:#303846; } .ai-file-picker { position:absolute; right:10px; bottom:78px; z-index:5; display:flex; flex-direction:column; gap:3px; max-width:min(320px, calc(100% - 20px)); max-height:45%; overflow:auto; padding:9px; background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.45); } .ai-file-picker[hidden] { display:none; } -.ai-file-picker strong { padding:3px 6px 6px; color:var(--muted); font-size:12px; } -.ai-file-option { width:100%; overflow:hidden; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:transparent; border-color:transparent; color:var(--text); font-size:12px; } -.ai-file-option:hover { background:#202833; } -.ai-form-in.drag .ai-inputbox { border-color:var(--accent); box-shadow:0 0 0 3px rgba(91,140,255,.18); } +.ai-form-in.drag .ai-inputbox { border-color:var(--accent); background:#111a28; box-shadow:0 0 0 3px rgba(91,140,255,.24), 0 0 18px rgba(91,140,255,.16); } +.ai-form-in.drag .ai-inputbox::after { content:'Отпустите файл, чтобы прикрепить'; position:absolute; inset:3px; z-index:4; display:grid; place-items:center; padding:8px; border:1px dashed rgba(157,184,255,.75); border-radius:9px; background:rgba(23,43,72,.88); color:#dce7ff; font-size:12px; font-weight:550; text-align:center; pointer-events:none; } @media (max-width: 640px) { /* A phone: the chat is a screen of its own, sliding up. */ .ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); } .ai-box.on { transform:none; } } -.ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; } +.ai-send { width:36px; height:36px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:9px; } +.ai-send .ai-plane { display:block; flex:none; transform:translateX(.5px); } /* The panel's own confirm and prompt. */ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560px, 92vw); } .uidlg form { padding:18px; display:grid; gap:14px; } @@ -928,8 +939,8 @@ body:not(.touch) .ai-head { cursor:grab; user-select:none; } body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; } body:not(.touch) .ai-fab { cursor:grab; touch-action:none; } /* Send: the field's height, square. */ -.ai-form-in textarea, .ai-send, .ai-attach, .ai-from-pane { box-sizing:border-box; } -.ai-send { width:40px; height:40px; align-self:flex-end; } +.ai-form-in textarea, .ai-send, .ai-attach { box-sizing:border-box; } +.ai-send { width:36px; height:36px; align-self:center; } @media (max-width: 640px) { .ai-fab { width:46px; height:46px; right:22px; bottom:24px; } body.touch.term-active .ai-fab { bottom:110px; } @@ -943,7 +954,7 @@ body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; } /* The field and the send button: one height on every phone, the button square whatever the text size. */ .ai-form-in textarea { min-height:40px; } -.ai-send { min-height:40px; max-height:40px; min-width:40px; max-width:40px; line-height:1; border-radius:10px; } +.ai-send { min-height:36px; max-height:36px; min-width:36px; max-width:36px; line-height:1; border-radius:9px; } .ai-form-in textarea { border-radius:10px; } /* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */ .ai-form .keybox { display:flex; gap:6px; align-items:center; } @@ -1081,3 +1092,6 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot table.machines th, table.machines td { text-align:center; } table.machines td.actions .row { justify-content:center; } } +/* The + menu's roots (Терминал, Файловый менеджер) a little to the left of + what they open, so the nesting reads. */ +.addmenu > .item { padding-left:7px; } diff --git a/internal/server/web/static/dnd.js b/internal/server/web/static/dnd.js index 17f1bc2..e2797b6 100644 --- a/internal/server/web/static/dnd.js +++ b/internal/server/web/static/dnd.js @@ -19,6 +19,9 @@ // start: a pointerdown on a row of pane; the drag begins past 6px. function start(ev, pane, tr) { if (ev.button !== 0 || ev.target.closest('input, button, a, .act')) return; + // Keep the file pane's pointer drag as the only drag source. Otherwise + // the browser can start a native text-selection drag beside our ghost. + ev.preventDefault(); const name = tr.dataset.name; const names = pane.selected.has(name) ? [...pane.selected] : [name]; d = {pane, names, x0: ev.clientX, y0: ev.clientY, id: ev.pointerId, on: false, @@ -56,6 +59,7 @@ d.target = null; box.hidden = true; d.ghost.dataset.hint = ''; + if (window.AIChat) window.AIChat.setDragging(false); // Over a tab's title: it opens after half a second. const tab = Workspace.tabAt(ev.clientX, ev.clientY); if (tab !== d.overTab) { @@ -64,6 +68,11 @@ if (tab) d.tabTimer = setTimeout(() => { if (d && d.overTab === tab) Workspace.activate(tab); }, 500); } if (tab) { d.ghost.dataset.hint = 'открыть вкладку'; return; } + if (window.AIChat && window.AIChat.isDropTargetAt(ev.clientX, ev.clientY)) { + window.AIChat.setDragging(true); + d.target = {kind: 'ai'}; + return; + } const pane = Workspace.paneAt(ev.clientX, ev.clientY); if (!pane || !pane.dropTarget) return; const t = pane.dropTarget(ev.clientX, ev.clientY); @@ -87,14 +96,23 @@ x.ghost.remove(); box.hidden = true; document.body.classList.remove('dnd'); + if (window.AIChat) window.AIChat.setDragging(false); justDropped = true; setTimeout(() => { justDropped = false; }, 0); if (cancel || !x.target) return; + if (x.target.kind === 'ai') { window.AIChat.dropFiles(x.pane, x.names); return; } const {pane, t, a} = x.target; Workspace.setActivePane(pane); if (a.kind === 'paste') pane.drop(x.item); else if (a.kind === 'transfer') ask(x.item, x.pane, t, ev.clientX, ev.clientY); } + // While FileDnD owns the pointer, never let the browser start a second, + // native drag (for example from the SVG icon or the filename under it). + document.addEventListener('dragstart', ev => { + if (!d) return; + ev.preventDefault(); + ev.stopImmediatePropagation(); + }, true); window.addEventListener('pointermove', move); window.addEventListener('pointerup', ev => end(ev)); window.addEventListener('pointercancel', ev => end(ev, true)); diff --git a/internal/server/web/static/docview.js b/internal/server/web/static/docview.js index 9d8466b..7874bc3 100644 --- a/internal/server/web/static/docview.js +++ b/internal/server/web/static/docview.js @@ -524,5 +524,152 @@ return sheets(list); } - window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs}; + // Extract text from PDF content streams without a heavyweight renderer or OCR. + // Flate-compressed page streams are inflated with the browser's native API. + async function pdf(buf) { + const input = new Uint8Array(buf); + let source = ''; + for (let i = 0; i < input.length; i += 0x8000) source += String.fromCharCode(...input.subarray(i, i + 0x8000)); + if (!source.startsWith('%PDF-')) throw new Error('это не PDF-файл'); + const objects = /\b(\d+)\s+\d+\s+obj\b/g, pages = []; + let inflatedBytes = 0, textBytes = 0, match; + async function inflate(data) { + const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate')).getReader(); + const chunks = []; let total = 0; + for (;;) { + const part = await reader.read(); + if (part.done) break; + total += part.value.length; + if (total > 16 * 1024 * 1024 || inflatedBytes + total > 32 * 1024 * 1024) { + await reader.cancel(); throw new Error('PDF stream слишком велик'); + } + chunks.push(part.value); + } + const result = new Uint8Array(total); let at = 0; + chunks.forEach(chunk => { result.set(chunk, at); at += chunk.length; }); + inflatedBytes += total; + return result; + } + while ((match = objects.exec(source))) { + const bodyStart = objects.lastIndex, possibleEnd = source.indexOf('endobj', bodyStart); + if (possibleEnd < 0) break; + const probe = source.slice(bodyStart, possibleEnd), stream = /\bstream\r?\n/.exec(probe); + if (!stream) { objects.lastIndex = possibleEnd + 6; continue; } + const dict = probe.slice(0, stream.index); + let start = bodyStart + stream.index + stream[0].length; + const length = /\/Length\s+(\d+)\b/.exec(dict); + let end = length ? start + Number(length[1]) : source.indexOf('endstream', start); + if (end < start) { objects.lastIndex = possibleEnd + 6; continue; } + let endObj = source.indexOf('endobj', length ? end : end + 9); + if (endObj < 0) break; + if (/\/Subtype\s*\/Image|\/Type\s*\/(?:ObjStm|XObject|Font|FontFile)/.test(dict)) { objects.lastIndex = endObj + 6; continue; } + let raw = source.slice(start, end); + if (!length) raw = raw.replace(/[\r\n]+$/, ''); + let data = Uint8Array.from(raw, c => c.charCodeAt(0) & 255); + if (dict.includes('/FlateDecode') && !dict.includes('/ASCII85Decode')) { + try { data = await inflate(data); } + catch (e) { objects.lastIndex = endObj + 6; continue; } + } else if (/\/Filter\b/.test(dict)) { objects.lastIndex = endObj + 6; continue; } + let content = ''; + for (let i = 0; i < data.length; i += 0x8000) content += String.fromCharCode(...data.subarray(i, i + 0x8000)); + if (content.includes('BT') && (content.includes('Tj') || content.includes('TJ'))) { + pages.push(content); + textBytes += content.length; + if (textBytes >= 2 * 1024 * 1024) break; + } + objects.lastIndex = endObj + 6; + } + const decode = bytes => { + if (bytes.length > 1 && bytes[0] === 0xfe && bytes[1] === 0xff) { + const swapped = new Uint8Array(bytes.length - 2); + for (let i = 2; i + 1 < bytes.length; i += 2) { swapped[i - 2] = bytes[i + 1]; swapped[i - 1] = bytes[i]; } + return new TextDecoder('utf-16le').decode(swapped); + } + return new TextDecoder('windows-1252').decode(bytes); + }; + function readString(s, at, literal) { + const bytes = []; + if (literal) { + let depth = 1, i = at + 1; + for (; i < s.length && depth; i++) { + let c = s.charCodeAt(i); + if (c === 92) { + c = s.charCodeAt(++i); + if (c === 110) bytes.push(10); else if (c === 114) bytes.push(13); else if (c === 116) bytes.push(9); + else if (c === 98) bytes.push(8); else if (c === 102) bytes.push(12); + else if (c === 10) { /* escaped line break */ } + else if (c === 13) { if (s.charCodeAt(i + 1) === 10) i++; } + else if (c >= 48 && c <= 55) { + let oct = String.fromCharCode(c); + for (let n = 0; n < 2 && /[0-7]/.test(s.charAt(i + 1)); n++) oct += s.charAt(++i); + bytes.push(parseInt(oct, 8) & 255); + } else bytes.push(c & 255); + } else if (c === 40) { depth++; bytes.push(c); } + else if (c === 41) { if (--depth) bytes.push(c); } + else bytes.push(c & 255); + } + return {value: decode(Uint8Array.from(bytes)), end: i}; + } + let hex = '', i = at + 1; + for (; i < s.length && s.charAt(i) !== '>'; i++) if (!/\s/.test(s.charAt(i))) hex += s.charAt(i); + if (hex.length % 2) hex += '0'; + for (let i = 0; i + 1 < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16)); + return {value: decode(Uint8Array.from(bytes)), end: i + 1}; + } + function textFrom(content) { + let out = '', inText = false, i = 0, operands = []; + const token = () => { + while (i < content.length && /[\x00\t\n\f\r ]/.test(content.charAt(i))) i++; + if (content.charAt(i) === '%') { while (i < content.length && content.charAt(i) !== '\n') i++; return token(); } + if (i >= content.length) return null; + const c = content.charAt(i); + if (c === '(') { const v = readString(content, i, true); i = v.end; return {type: 'str', value: v.value}; } + if (c === '<' && content.charAt(i + 1) !== '<') { const v = readString(content, i, false); i = v.end; return {type: 'str', value: v.value}; } + if (c === '[') { + i++; const values = []; + while (i < content.length) { + while (/[\x00\t\n\f\r ]/.test(content.charAt(i))) i++; + if (content.charAt(i) === ']') { i++; break; } + if (content.charAt(i) === '(' || (content.charAt(i) === '<' && content.charAt(i + 1) !== '<')) { + const v = readString(content, i, content.charAt(i) === '('); i = v.end; values.push(v.value); + } else { + const number = /^[+-]?(?:\d+\.?\d*|\.\d+)/.exec(content.slice(i)); + if (number) { values.push(Number(number[0])); i += number[0].length; } else i++; + } + } + return {type: 'array', value: values}; + } + if (c === '/') { i++; while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++; return {type: 'name'}; } + const start = i++; + while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++; + return {type: 'word', value: content.slice(start, i)}; + }; + while (i < content.length) { + const t = token(); + if (!t) break; + if (t.type !== 'word' || !/^[A-Za-z*'"]+$/.test(t.value)) { operands.push(t); continue; } + const op = t.value; + if (op === 'BT') { inText = true; operands = []; continue; } + if (op === 'ET') { inText = false; out += '\n'; operands = []; continue; } + if (inText && ['Tj', 'TJ', "'", '"'].includes(op)) { + if (op === "'" || op === '"') out += '\n'; + const v = operands[operands.length - 1]; + if (v && v.type === 'str') out += v.value; + else if (v && v.type === 'array') out += v.value.map(x => typeof x === 'number' ? (x < -120 ? ' ' : '') : x).join(''); + } else if (inText && op === 'Tm') { + if (out && !out.endsWith('\n')) out += '\n'; + } else if (inText && (op === 'T*' || op === 'Td' || op === 'TD')) { + if (op === 'T*' || Number(operands[operands.length - 1]?.value) !== 0) out += '\n'; + else out += ' '; + } + operands = []; + } + return out; + } + const text = pages.map(textFrom).join('\n').replace(/[\t ]+\n/g, '\n').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); + if (!text) throw new Error('в PDF не найден извлекаемый текст (сканированные страницы без OCR не читаются)'); + return text.slice(0, 120000); + } + + window.DocView = {docx, xlsx, ods, odt, csv, doc, pdf, sqlite, bindTabs}; })(); diff --git a/internal/server/web/static/docview.test.js b/internal/server/web/static/docview.test.js new file mode 100644 index 0000000..a850d8c --- /dev/null +++ b/internal/server/web/static/docview.test.js @@ -0,0 +1,32 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const zlib = require('node:zlib'); + +const runtime = {window: {}, Blob, Response, DecompressionStream, TextDecoder, Uint8Array}; +vm.createContext(runtime); +vm.runInContext(fs.readFileSync(path.join(__dirname, 'docview.js'), 'utf8'), runtime); + +function pdfWithStream(content, compressed = false) { + const stream = compressed ? zlib.deflateSync(Buffer.from(content, 'latin1')) : Buffer.from(content, 'latin1'); + const filter = compressed ? ' /Filter /FlateDecode' : ''; + const prefix = `%PDF-1.4\n1 0 obj\n<< /Length ${stream.length}${filter} >>\nstream\n`; + return Buffer.concat([Buffer.from(prefix, 'latin1'), stream, Buffer.from('\nendstream\nendobj\n%%EOF', 'latin1')]); +} + +test('PDF text extraction handles literal strings and escaped parentheses', async () => { + const pdf = pdfWithStream('BT /F1 12 Tf 72 700 Td (Hello \\(PDF\\)) Tj ET'); + assert.equal(await runtime.window.DocView.pdf(pdf), 'Hello (PDF)'); +}); + +test('PDF text extraction inflates streams and keeps TJ word spacing', async () => { + const pdf = pdfWithStream('BT /F1 12 Tf [(lightweight) -250 ( parser)] TJ ET', true); + assert.equal(await runtime.window.DocView.pdf(pdf), 'lightweight parser'); +}); + +test('PDF text extraction reports scanned or non-text PDFs without OCR', async () => { + const pdf = pdfWithStream('q 0 0 100 100 re f Q'); + await assert.rejects(runtime.window.DocView.pdf(pdf), /без OCR/); +}); diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index b53fbf0..e705eb7 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -38,8 +38,8 @@ else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img'; else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc'; else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media'; - else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code'; - else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text'; + else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|xml)$/i.test(e.name)) k = 'code'; + else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text'; return '' + FICONS[k] + ''; } @@ -219,7 +219,6 @@ const r = await this.api('list', {path}); if (!r.ok) { if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true); - this.q('.fp-path').value = this.cwd; return false; } this.show(r.data); @@ -257,6 +256,8 @@ this.updateSel(); for (const e of this.sorted()) { const tr = document.createElement('tr'); + tr.draggable = false; + tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); }); if (e.is_dir) tr.dataset.dir = e.name; tr.dataset.name = e.name; const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name); @@ -272,7 +273,7 @@ '' + '
'; const dl = tr.querySelector('a[download]'); - if (dl) dl.href = this.src.url('download', join(this.cwd, e.name)); + if (dl) { dl.draggable = false; dl.href = this.src.url('download', join(this.cwd, e.name)); } tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e); tr.querySelector('.ck').checked = this.selected.has(e.name); tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); }; diff --git a/internal/server/web/static/fileviews.js b/internal/server/web/static/fileviews.js index f0cd738..8d5a52c 100644 --- a/internal/server/web/static/fileviews.js +++ b/internal/server/web/static/fileviews.js @@ -110,7 +110,7 @@ js:'javascript', mjs:'javascript', cjs:'javascript', jsx:'javascript', ts:'text/typescript', tsx:'text/typescript', json:'application/json', css:'css', scss:'text/x-scss', less:'text/x-less', html:'htmlmixed', htm:'htmlmixed', vue:'htmlmixed', xml:'xml', svg:'xml', plist:'xml', xsd:'xml', php:'php', sh:'shell', bash:'shell', zsh:'shell', ksh:'shell', yaml:'yaml', yml:'yaml', md:'markdown', markdown:'markdown', sql:'sql', - toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties', + toml:'toml', ini:'properties', cfg:'properties', conf:'properties', cnf:'properties', env:'properties', properties:'properties', ovpn:'properties', service:'properties', timer:'properties', socket:'properties', mount:'properties', lua:'lua', rs:'rust', diff:'diff', patch:'diff', rb:'ruby', pl:'perl', pm:'perl', cmake:'cmake' }; diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 688e5ae..24a49eb 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -147,20 +147,25 @@ placeFloat(); } // With one tab and a file pane in the top right corner, the "+" joins - // that pane's top bar: right of "Обновить", above "Удалить выбранное", - // a button of the same size. It is positioned in .area, not the scrolling - // .fpane-body, so it stays beside the path field while the file list scrolls. - // Otherwise it is back in the corner. + // that pane's first toolbar after "Обновить". It belongs to the scrolling + // file pane, so it stays beside the toolbar when the pane is scrolled. + // Otherwise it returns to the corner of .area. function placeFloat() { const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null; const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : []; - // A bar folded in two (a phone) keeps the corner "+". + // A bar folded in two (a phone or a narrow pane) keeps the corner "+". const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48; floatAdd.classList.toggle('in-files', on); - if (!on) { floatAdd.style.top = floatAdd.style.right = ''; return; } - const a = area.getBoundingClientRect(), top = bars[0].getBoundingClientRect(), row = bars[1].getBoundingClientRect(); - floatAdd.style.top = (top.top - a.top - area.clientTop) + 'px'; - floatAdd.style.right = (a.left + area.clientLeft + area.clientWidth - row.right) + 'px'; + if (on) { + bars[0].appendChild(floatAdd); + if (bars[0].offsetHeight >= 48) { + floatAdd.classList.remove('in-files'); + area.insertBefore(floatAdd, area.firstChild); + } + } else { + area.insertBefore(floatAdd, area.firstChild); + } + floatAdd.style.top = floatAdd.style.right = ''; } // One tab: a plain terminal, the strip folded into the corner "+". function updateMode() { @@ -344,6 +349,14 @@ // it. A terminal from a split, by its title bar: to the strip to make it a // tab of its own, or onto another terminal of its tab to swap places. let drag = null, dragJustEnded = false; + // Safari may start a native drag for the tab or header text while our + // pointer-based drag is active, producing a second image under the cursor. + // External files are unaffected: their dragstart originates outside this page. + document.addEventListener('dragstart', ev => { + if (!drag && !document.body.classList.contains('ai-dragging')) return; + ev.preventDefault(); + ev.stopImmediatePropagation(); + }, true); function startDrag(ev, kind, obj) { if (ev.button !== 0) return; drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId }; diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js index ed6d90e..c133bbe 100644 --- a/internal/server/web/static/wsutil.js +++ b/internal/server/web/static/wsutil.js @@ -48,11 +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|search)([^\n]*)\n([\s\S]*?)```/g; + const out = [], re = /```(run|read|ls|write|search|fetch)([^\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; } + if (kind === 'search' || kind === 'fetch') { 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 1b616ba..c6a5356 100644 --- a/internal/server/web/static/wsutil.test.js +++ b/internal/server/web/static/wsutil.test.js @@ -80,3 +80,8 @@ test('parseActions: search blocks', () => { assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'), [{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]); }); + +test('parseActions: fetch blocks', () => { + assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'), + [{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]); +}); diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 13191cc..57dbbea 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -88,10 +88,7 @@

URL, на который звонят агенты. Подставляется в скрипт установки.

-

Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной - недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик - качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост. - Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).

+

Адрес сервера в обход прокси: агенты и установщик переходят на него, если основной не отвечает.

@@ -153,9 +150,7 @@

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

-

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

-
+

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

-

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

+

Запасной - DuckDuckGo.