Go 1.24
Generic type aliases are here.
+- Swiss tables
- weak pointers
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)
]*>.*?`) + 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
`
+ 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 => '';
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 = '';
+ 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 @@
ИИ-ассистент
- Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
- пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.
-