30 Commits
Author SHA1 Message Date
redtea 3f288c6fd6 release: 0.2.5 2026-10-18 12:33:40 +03:00
redtea 3d2f886f04 feat: Настройки › Автообновление - clients compare their version with the server's every N and update themselves; the server looks at GitLab every N and, when a newer release is out, offers it to an administrator at login (after the PIN) with «Обновить» and «Отказаться»; the words of a made picture's result are the model's only 2026-10-18 12:33:24 +03:00
redtea 916a49958b release: 0.2.4 2026-10-18 12:13:47 +03:00
redtea dde85031aa feat: the text of a picture's or a search's card folds and unfolds smoothly 2026-10-18 12:13:29 +03:00
redtea dd1244a169 feat: pictures for the assistant - the models make and edit them by themselves through the Vertex API (a new picture, or the user's attached photo, or the last picture made), with an API key in Настройки and a switch for the prompt improver; folded descriptions of pictures and searches; the name of an attached file opens or saves it; a deleted chat is wiped from the database files, attachments included 2026-10-18 12:12:54 +03:00
redtea c72fcfebf3 fix: http-lp forwarding - a download the agent finished reading reaches the browser whole; the session closing on its end of stream cut off what was still queued (up to 32 MB), so browsers restarted big downloads 2026-10-18 11:38:05 +03:00
redtea 054fc97da8 fix: the assistant's web search and page reading on by default, as the chat - they refused («ассистент выключен в профиле») anyone who never touched the switch 2026-10-18 11:26:20 +03:00
redtea 114f8b1e1b release: 0.2.3 rebuilt with the Порты fix 2026-10-18 11:22:19 +03:00
redtea a1604206ee fix: Порты - the «Без ufw» switch in the line of the note, at its right, never over it when the button wraps 2026-10-18 11:22:01 +03:00
redtea a107745355 release: 0.2.3 2026-10-18 11:16:25 +03:00
redtea dcbc602d88 feat: Настройки › ИИ-ассистент - each model and the web search folded to a line (name, kind and model), opened when wanted; a new model opens, its line follows its fields 2026-10-18 11:16:08 +03:00
redtea fcf032042b release: 0.2.2 2026-10-18 11:09:16 +03:00
redtea 01f02360c4 chore: the GitLab namespace is itzredtea now 2026-10-18 11:03:31 +03:00
redtea 57de7bf967 chore: move to GitLab - server-install.sh takes the latest release and dist/ from gitlab.com/itzRedTea/lpra, README links and screenshots kept in the repo; a guide for moving other projects 2026-10-18 10:44:18 +03:00
redtea 173f46a145 fix: Qwen Omni gets its files right - laid out by the app's own inputs (audio before image there, they were swapped), a video as Gradio 5 takes one ({video, subtitles}); the model is told it has the files, so it answers about them 2026-10-17 15:42:37 +03:00
redtea 03f963340e feat: «Думать» uses a model's own thinking when it has it (OpenAI-compatible reasoning, Anthropic extended thinking) instead of asking it to think in words too; learned per model, the word-thinking prompt kept for models without; without «Думать» such a model is asked not to think 2026-10-17 15:36:35 +03:00
redtea 37ef886503 feat: the model's short name up to 12 characters 2026-10-17 15:33:19 +03:00
redtea 6c1da79b4a feat: the model's short name up to 9 characters, the chat 408px wide so nine of the widest letters fit 2026-10-17 15:07:49 +03:00
redtea 6a17f40042 feat: a short name of up to 7 characters for each model, on the chat's model button, always whole; the message field back to the normal 14px 2026-10-17 14:58:04 +03:00
redtea 351e4ad28d fix: the chat's message field lower - 42px for a line instead of 52, the clip and send to scale, still centred on the last line 2026-10-17 14:47:42 +03:00
redtea 983809714b feat: several models for the assistant, each with its own name, kind, address and key; a model menu in the chat when there are several (or always, from Аккаунт › Вид), the choice kept for the user 2026-10-17 14:41:34 +03:00
redtea ded86e1c3f fix: the panel's input dialog - a dark field as everywhere, with the accent outline when focused 2026-10-16 22:08:30 +03:00
redtea b4bcc05162 docs: a plan for flow control in the direct tunnel, for when one slow connection stalling an agent's others starts to matter 2026-10-16 22:02:59 +03:00
redtea e8d45f2f7b docs: http-lp forwarding goes over the agent's WebSocket, by long polls only without one - said so in the panel, the code and the README 2026-10-16 21:56:24 +03:00
redtea e93a2bbd3a fix: the stop button blue again, as the plane 2026-10-16 21:51:03 +03:00
redtea e0b3999d0c feat: a chat of the assistant is deleted at once, without asking 2026-10-16 21:47:50 +03:00
redtea 77322e8644 chore: update build 2026-10-16 21:44:43 +03:00
redtea 6085b5a094 fix: the stop square fades away as a red square and becomes the plane only once hidden; the square a little bigger 2026-10-16 21:44:09 +03:00
redtea e0cc5a06b7 fix: server-install.sh - an answer «n» about nginx no longer counts as nginx set up, so without nginx the server listens on every address 2026-10-16 21:24:27 +03:00
redtea 667c6177f4 feat: server-install.sh - install or update the server in one command: the latest release's binary, dist/ from main, the systemd service, nginx with a domain and certbot if wanted, a health check 2026-10-16 20:47:26 +03:00
54 changed files with 3620 additions and 144 deletions

No files matched your search

+48 -7
View File
@@ -45,11 +45,21 @@
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
ответом). Модель задаёт администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию -
прокси к Qwen Omni), OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
название в чате (и короткое, до 12 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
можно показывать выбор всегда.
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
@@ -57,6 +67,13 @@
не расшифровать.
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
- **Автообновление** («Настройки › Автообновление», обе части выключены, пока не включены):
*клиентов* - агенты раз в N минут/часов/суток сверяют свою версию с версией сервера и, если у сервера новее,
обновляются сами (как по кнопке «Обновить»; нужен root или sudo без пароля; агентам до 0.2.5 один раз обновиться
придётся вручную); *сервера* - сервер раз в N сверяет себя с последним релизом на GitLab, и когда вышел новый,
администратору при входе (после PIN) показывается окно «Обновить / Отказаться» - отказ запоминается для этой
версии. «Обновить» запускает `server-install.sh` отдельной службой systemd (нужны root и systemd); там же кнопки
«Проверить сейчас» и «Обновить сейчас».
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
@@ -92,6 +109,29 @@ PostgreSQL: хост, порт, база, пользователь, пароль
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
## Установка сервера одной командой
На Linux-сервере (amd64 или arm64, systemd):
```sh
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
```
Скрипт `server-install.sh`:
- качает бинарник сервера под архитектуру машины из [последнего релиза](https://gitlab.com/itzredtea/lpra/-/releases/permalink/latest)
и папку `dist/` (бинарники агентов) из ветки `main`, кладёт всё в `/opt/lpra`;
- ставит службу systemd `lpra-server` (порт спросит, по умолчанию `8091`);
- по желанию настраивает nginx: спросит домен, создаст сайт на порт 80 (при необходимости поставит nginx),
затем `certbot --nginx` - если certbot с плагином nginx не установлен, этот шаг пропускается;
- если включён ufw - предложит открыть 80, 443 и туннель `8446` tcp/udp;
- запускает службу, перезагружает nginx и проверяет, что сервер отвечает.
Вопросы задаются в терминале, даже когда скрипт идёт через `| sudo sh`. Без терминала ответы берутся
из переменных: `LPRA_PORT`, `LPRA_NGINX` (y/n), `LPRA_DOMAIN`, `LPRA_EMAIL`, `LPRA_CERTBOT` (y/n), `LPRA_UFW` (y/n):
```sh
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo LPRA_NGINX=y LPRA_DOMAIN=lpra.example.com sh
```
Повторный запуск той же командой - **обновление**: бинарник и `dist/` заменяются, база (`/opt/lpra/lpra.db`
и её ключ `lpra.db.key`), настройки службы и nginx остаются. После установки откройте `/setup` - мастер
создаст администратора.
## Запуск сервера
```sh
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
@@ -159,7 +199,8 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
- **lp** - только long-polling.
## Проброс портов и прямой туннель
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
Правила создаются на странице машины «Порты». Режим `http` (в панели - http-lp) идёт через
соединение агента с панелью: по WebSocket, а если он недоступен - по long-polling;
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
по нему мультиплексируются все соединения.
@@ -186,10 +227,10 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
## Скриншоты
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
<img width="1256" height="709" alt="image" src="docs/screenshots/1.png" />
<img width="1254" height="532" alt="image" src="https://github.com/user-attachments/assets/0207a7f0-2b59-40ee-abd1-d8673059cc24" />
<img width="1254" height="532" alt="image" src="docs/screenshots/2.png" />
<img width="1255" height="526" alt="image" src="https://github.com/user-attachments/assets/4dcbe907-33f5-493e-a56f-01cd8bf0fea1" />
<img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
<img width="1260" height="654" alt="image" src="https://github.com/user-attachments/assets/fdf59733-8e5d-46e3-b232-235ddfe1be73" />
<img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
+1 -1
View File
@@ -1 +1 @@
0.2.1
0.2.5
+4
View File
@@ -6,6 +6,7 @@
package main
import (
"context"
"flag"
"log"
"net/http"
@@ -42,6 +43,9 @@ func main() {
log.Fatalf("init server: %v", err)
}
// The server updates itself, if told to (Настройки › Автообновление).
go srv.RunAutoUpdate(context.Background())
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
# Перенос проекта с GitHub на GitLab с сохранением истории и дат
Инструкция для локального репозитория, когда GitHub недоступен (аккаунт заблокирован) и
переносить приходится из своей копии. Так был перенесён `lpra`: 195 коммитов, 8 тегов,
3 релиза - хэши и даты совпали.
## Что сохраняется, а что нет
| Что | Сохраняется? |
|---|---|
| Даты коммитов (автора и коммита) | **Да, точно.** Они хранятся внутри коммита; push той же истории не меняет ни одного хэша |
| Ветки и теги | Да, если они есть локально (см. шаг 2) |
| Дата создания проекта | **Нет.** На gitlab.com она всегда - день создания проекта, задним числом не ставится. Начало проекта видно по первому коммиту |
| Релизы | Создаются заново, **с исходной датой** (`--released-at`); файлы релизов - заново собрать или взять свои копии |
| Issues, PR, звёзды, Actions | Нет: при заблокированном GitHub их не выгрузить |
**Нельзя:** `rebase`, `squash`, `filter-branch`, `filter-repo`, `commit --amend` по старым
коммитам, «Import from GitHub» с чужой копии - всё это переписывает коммиты и их даты.
## 0. Один раз: вход в GitLab
```sh
brew install glab
glab auth login --hostname gitlab.com --web --git-protocol https
```
Вход через браузер даёт права и на push, и на релизы. Git берёт пароль у glab - для каждого
репозитория это настраивается в шаге 4 (или один раз для всех:
`git config --global credential.https://gitlab.com.helper '!glab auth git-credential'`).
SSH-ключ тоже подойдёт, но в GitLab у ключа должен быть **Usage type: Authentication & Signing**
(с типом «Signing» по SSH не пускает).
## 1. Пустой проект в GitLab
https://gitlab.com/projects/new → **Create blank project** → имя, владелец `itzredtea`.
**Снять галочку «Initialize repository with a README»** - иначе в проекте появится свой первый
коммит, и историю придётся склеивать.
Или из терминала:
```sh
glab repo create itzredtea/ИМЯ --public # или --private; README не создаётся
```
## 2. Проверить, что всё есть локально
```sh
cd ~/CodeRepos/ИМЯ
git status # незакоммиченное закоммитить или убрать
git branch -a # ветки: свои и remotes/origin/*
git tag -l # теги
```
- Ветки, которых нет локально, но есть `remotes/origin/X`, тоже переносятся (шаг 3).
- **Теги, созданные на GitHub при публикации релиза, могут не быть локально** - если их не
делали `git fetch --tags`. Такой тег создаётся заново на нужном коммите (шаг 5). Коммит
ищется по дате публикации релиза: последний коммит перед ней
(`git log --format='%h %cd %s' --date=iso --until='ДАТА'`).
Снимок до переноса - для сверки:
```sh
git log --branches --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/before.txt
git show-ref --tags | sort > /tmp/tags-before.txt
```
## 3. Отправить историю
```sh
git remote rename origin github # GitHub остаётся под своим именем
git remote add origin https://gitlab.com/itzredtea/ИМЯ.git
H='!glab auth git-credential'
git -c credential.helper= -c "credential.helper=$H" push origin --all # все локальные ветки
git -c credential.helper= -c "credential.helper=$H" push origin 'refs/remotes/github/*:refs/heads/*' # ветки, что были только на GitHub
git -c credential.helper= -c "credential.helper=$H" push origin --tags
```
Вторая команда push может сказать про `HEAD` - это не ветка, сообщение можно не смотреть.
## 4. Сверить и переключиться
Сверка - по свежей копии из GitLab:
```sh
rm -rf /tmp/check && git clone -q https://gitlab.com/itzredtea/ИМЯ.git /tmp/check
cd /tmp/check && git log --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/after.txt
git show-ref --tags | sort > /tmp/tags-after.txt
diff /tmp/before.txt /tmp/after.txt && echo "коммиты и даты совпали"
diff /tmp/tags-before.txt /tmp/tags-after.txt && echo "теги совпали"
```
Пустой `diff` - история перенесена один в один. Дальше работать с GitLab:
```sh
cd ~/CodeRepos/ИМЯ
git config --local credential.https://gitlab.com.helper '!glab auth git-credential'
git fetch origin && git branch -u origin/main main
```
## 5. Релизы с исходными датами
Дата - время публикации релиза на GitHub (UTC). Файлы - свои копии или сборка из коммита
релиза во временной папке:
```sh
git worktree add --detach /tmp/rel-1.2.0 1.2.0 # тег или хэш коммита релиза
cd /tmp/rel-1.2.0 && ./build.sh # как проект собирается
glab release create 1.2.0 bin/файл1 bin/файл2 \
-R itzredtea/ИМЯ --name "Release 1.2.0" \
--released-at "2026-10-15T16:34:53Z" \
--notes "Перенесено с GitHub, дата публикации исходная."
# тега нет (его делал GitHub) - добавить: --ref <полный хэш коммита>
git worktree remove /tmp/rel-1.2.0
```
Создавать по порядку, от старых к новым. Последним GitLab считает релиз с самой поздней
датой. Ссылки на файлы:
- конкретный релиз: `https://gitlab.com/itzredtea/ИМЯ/-/releases/ТЕГ/downloads/ФАЙЛ`
- последний: `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ`
## 6. Ссылки в самом проекте
Найти и заменить всё, что смотрит на GitHub:
```sh
grep -rn "github.com\|githubusercontent" --exclude-dir=.git .
```
| GitHub | GitLab |
|---|---|
| `https://raw.githubusercontent.com/USER/ИМЯ/main/ПУТЬ` | `https://gitlab.com/itzredtea/ИМЯ/-/raw/main/ПУТЬ` |
| `https://github.com/USER/ИМЯ/releases/latest/download/ФАЙЛ` | `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ` |
| `https://api.github.com/repos/USER/ИМЯ/releases/latest` (`tag_name`) | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/releases/permalink/latest` (`tag_name`) |
| `https://api.github.com/repos/USER/ИМЯ/contents/ПАПКА?ref=main` | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/repository/tree?path=ПАПКА&ref=main` (поле `path`) |
| картинки `github.com/user-attachments/...` | скачать, пока открываются, в `docs/` и ссылаться на файлы |
Путь Go-модуля (`module github.com/...` в `go.mod`) можно не трогать: это только имя.
## 7. После переноса
- Описание проекта в GitLab: «Перенесён с GitHub, начат ДД.ММ.ГГГГ» - раз дата создания новая.
- Токены, которые светились в чатах и логах, - отозвать и выпустить новые.
+73
View File
@@ -0,0 +1,73 @@
# План: управление потоком в прямом туннеле (tcp-проброс)
Статус: **отложено** - сделать, если на практике упрёмся. Записано 2026-10-16.
## Что сейчас
Прямой туннель (`:8446`, режим проброса `tcp`) - одно TCP-соединение на агента, по нему
мультиплексируются все проброшенные соединения этого агента кадрами
`type(1) | stream(4) | length(4) | payload` (`internal/tunnel/frame.go`).
Ёмкости хватает с запасом: на агента - одно соединение и одна горутина чтения, на проброшенное
соединение - горутина и буфер 32 КБ; шифрования в туннеле нет. 20 агентов и тысячи соединений -
десятки МБ памяти и почти ноль CPU. Потолок - сеть хоста (1 Гбит/с), через которую весь трафик
идёт дважды (пользователь ↔ хост ↔ агент).
## Проблема: блокировка всего туннеля агента одним соединением
Управления потоком (окон, как в HTTP/2 или yamux) нет, поэтому внутри одного агента медленное
соединение тормозит остальные:
- **Хост** (`internal/server/tunnelserver.go`, `agentTunnel.readLoop`): кадр `TypeData` пишется
пользователю синхронно, `c.Write(f.Payload)`, без дедлайна. Клиент, который медленно читает или
завис, останавливает чтение туннеля - встают все пробросы этого агента, пока клиент не отвалится.
- **Агент** (`internal/agent/tunnel.go`, `tunClient.readLoop`): данные кладутся в канал потока
`in` (256 кусков, до ~8 МБ). Если локальный сервис не читает и канал полон, `readLoop`
блокируется на `s.in <- f.Payload` - встаёт весь туннель агента.
Между агентами влияния нет: у каждого свой `readLoop`.
Когда проявится: через одного агента одновременно большая отдача медленному клиенту и что-то
интерактивное (SSH, веб) - интерактивное подвисает. Худший случай - зависший клиент держит
туннель агента до разрыва TCP.
## Предлагаемое решение
1. **Своя очередь записи у каждого потока** - на хосте и на агенте. `readLoop` только кладёт
кадр в очередь потока и никогда не пишет в сокет сам; пишет отдельная горутина потока.
2. **Окно (кредиты) на поток.** Новый тип кадра `TypeWindow` (stream, uint32 байт): получатель
сообщает, сколько ещё байт готов принять. Отправитель не шлёт `TypeData` сверх окна.
Начальное окно - 256 КБ; получатель возвращает кредит по мере записи в сокет (например, когда
записана половина окна). Так очередь потока ограничена окном, а медленный поток тормозит
только себя.
3. **Дедлайны записи.** Запись в пользовательский или локальный сокет - с `SetWriteDeadline`
(например, 60 с без прогресса). Не успел - поток закрывается (`TypeClose`), туннель живёт.
4. **Справедливость отправки.** Отправка в туннель уже сериализована (`wmu`); кадры данных
ограничить 32 КБ (сейчас так и есть), чтобы большой поток не занимал канал надолго.
## Совместимость со старыми агентами
Рукопожатие `LPRA-TUN1` не меняем: старый агент отвергает другое приветствие, а старый сервер -
ответ не из двух полей. Вместо этого:
- сервер знает версию агента (из БД, `versionAtLeast`) и, если агент новый, сразу после
рукопожатия шлёт кадр `TypeHello` (флаги: `flowControl`);
- агент включает окна, только получив `TypeHello`. Старый сервер его не шлёт - новый агент
работает с ним по-старому. Старому агенту сервер `TypeHello` не шлёт и окна не включает (а
пришедший неизвестный кадр старый агент и так пропустил бы: в его `readLoop` нет `default`).
Новые типы кадров: `TypeHello = 5`, `TypeWindow = 6`.
## Тесты
- Медленный читатель на одном потоке не задерживает другой поток того же агента (замер задержки
эха на втором потоке при забитом первом).
- Зависший клиент закрывается по дедлайну, туннель и остальные потоки живы.
- Новый сервер + старый агент и старый сервер + новый агент - проброс работает, как раньше.
- Пропускная способность одного потока не падает заметно (окно 256 КБ на RTT до 50 мс даёт
~40 Мбит/с на поток; если мало - окно больше или автоподстройка).
## Объём
Сервер: `tunnelserver.go` (~150 строк), агент: `tunnel.go` (~150 строк), `frame.go` (типы), тесты.
Нужна новая версия агента; панель после обновления помечает старых как устаревших.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

+95
View File
@@ -0,0 +1,95 @@
package agent
import (
"context"
"log"
"net/http"
"strconv"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Auto-update, the agent's part: the server tells (with every poll, on its
// config and on the WebSocket) its own version and how often to compare -
// in minutes, 0 for never. When the server's version is newer, the agent
// takes the new one from it, as the panel's «Обновить» does.
// autoUpdate is what the agent was told, and when it last looked.
type autoUpdate struct {
mu sync.Mutex
minutes int
serverVer string
last time.Time
}
func (a *Agent) applyUpdateInfo(minutes int, serverVersion string) {
a.auto.mu.Lock()
defer a.auto.mu.Unlock()
if minutes < 0 {
minutes = 0
}
a.auto.minutes = minutes
if serverVersion != "" {
a.auto.serverVer = serverVersion
}
}
// applyUpdateHeaders takes the settings from the headers of an answer; an
// answer without them (an older server) changes nothing.
func (a *Agent) applyUpdateHeaders(h http.Header) {
v := h.Get(proto.AutoUpdateHeader)
if v == "" {
return
}
m, err := strconv.Atoi(v)
if err != nil {
m = 0
}
a.applyUpdateInfo(m, h.Get(proto.ServerVersionHeader))
}
func (a *Agent) updateInfo() (int, string) {
a.auto.mu.Lock()
defer a.auto.mu.Unlock()
return a.auto.minutes, a.auto.serverVer
}
func (a *Agent) autoUpdateOn() bool { m, _ := a.updateInfo(); return m > 0 }
// autoUpdateTick: one look at the clock. Out of the interval since the last
// look, it looks; the server's version newer than its own, it updates.
// Reports whether it did.
func (a *Agent) autoUpdateTick(now time.Time, update func() proto.TaskResult) bool {
a.auto.mu.Lock()
if a.auto.minutes <= 0 || (!a.auto.last.IsZero() && now.Sub(a.auto.last) < time.Duration(a.auto.minutes)*time.Minute) {
a.auto.mu.Unlock()
return false
}
a.auto.last = now
server := a.auto.serverVer
a.auto.mu.Unlock()
if !proto.VersionNewer(server, Version) {
return false
}
log.Printf("auto-update: server has %s, this is %s - updating", server, Version)
if res := update(); !res.OK {
log.Printf("auto-update: %s", res.Error)
}
return true
}
// autoUpdateLoop watches the clock until ctx ends.
func (a *Agent) autoUpdateLoop(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
a.autoUpdateTick(now, a.doUpdate)
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package agent
import (
"net/http"
"testing"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// updates counts what an agent was made to update with.
type updates struct{ n int }
func (u *updates) do() proto.TaskResult { u.n++; return proto.TaskResult{OK: true} }
// The agent is told, by headers or by the WebSocket's message, how often to
// compare versions; nothing told, nothing done.
func TestAutoUpdateIsTold(t *testing.T) {
a := New(Config{})
if a.autoUpdateOn() {
t.Fatal("on with nothing told")
}
h := http.Header{}
h.Set(proto.AutoUpdateHeader, "90")
h.Set(proto.ServerVersionHeader, "9.9.9")
a.applyUpdateHeaders(h)
if m, v := a.updateInfo(); m != 90 || v != "9.9.9" {
t.Fatalf("by headers: %d %q", m, v)
}
a.applyUpdateInfo(0, "9.9.9")
if a.autoUpdateOn() {
t.Fatal("still on after being told 0")
}
a.applyUpdateInfo(30, "9.9.9")
if m, _ := a.updateInfo(); m != 30 {
t.Fatalf("by the message: %d", m)
}
// Rubbish is not taken for a number.
h.Set(proto.AutoUpdateHeader, "soon")
a.applyUpdateHeaders(h)
if m, _ := a.updateInfo(); m != 0 {
t.Fatalf("rubbish: %d", m)
}
}
// When the interval is out the agent looks; it updates only to a newer
// version of the server's, and tries again the next time if it did not work.
func TestAutoUpdateTick(t *testing.T) {
a := New(Config{})
u := &updates{}
now := time.Now()
a.applyUpdateInfo(60, "9.9.9")
if !a.autoUpdateTick(now, u.do) || u.n != 1 {
t.Fatalf("a newer server: %d updates", u.n)
}
if a.autoUpdateTick(now.Add(30*time.Minute), u.do) || u.n != 1 {
t.Fatalf("before the interval is out: %d updates", u.n)
}
if !a.autoUpdateTick(now.Add(61*time.Minute), u.do) || u.n != 2 {
t.Fatalf("at the next time: %d updates", u.n)
}
// The same, an older one, a build that is not a version: nothing.
for _, v := range []string{Version, "0.0.1", "dev", ""} {
b := New(Config{})
b.applyUpdateInfo(60, v)
if b.autoUpdateTick(now, u.do) {
t.Fatalf("updated to %q", v)
}
}
// Off: nothing, whatever the server has.
c := New(Config{})
c.applyUpdateInfo(0, "9.9.9")
if c.autoUpdateTick(now, u.do) || u.n != 2 {
t.Fatalf("off, yet updated: %d", u.n)
}
}
+5
View File
@@ -64,6 +64,8 @@ type Agent struct {
tunMu sync.Mutex
tunAddr string
tunCancel context.CancelFunc
auto autoUpdate // what the server told of auto-update
}
// New builds an agent. It does not perform any network I/O.
@@ -115,6 +117,7 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
go a.watchMain(ctx)
go a.autoUpdateLoop(ctx)
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
@@ -321,6 +324,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
a.applyTunnelHeader(ctx, resp.Header)
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.applyUpdateHeaders(resp.Header)
a.dispatch(ctx, msg)
return nil
}
@@ -356,6 +360,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
defer resp.Body.Close()
a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
a.applyUpdateHeaders(resp.Header)
return a.applyTunnelHeader(ctx, resp.Header)
}
+4 -2
View File
@@ -33,7 +33,8 @@ func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
}
// runForward dials 127.0.0.1:port on the client and bridges it to the session,
// giving the host a reverse TCP tunnel over the same long-poll transport.
// giving the host a reverse TCP tunnel over the agent's link to the server:
// the WebSocket, or the long polls without one.
func (a *Agent) runForward(parent context.Context, sid string, port int) {
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
@@ -92,7 +93,8 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
}
}
// pollIn performs one downstream long-poll and applies the frames.
// pollIn takes the next input - pushed on the WebSocket, else by a long
// poll - and applies the frames.
// Returns false when the session is gone and the loop should stop.
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
// On the WebSocket the input comes pushed; if it drops, by a long poll.
+1
View File
@@ -167,6 +167,7 @@ func (a *Agent) runWS(ctx context.Context) error {
a.applyTunnelAddr(ctx, m.Tunnel)
a.learnAddrs(m.Main, m.Fallback)
a.applyTransport(m.Transport)
a.applyUpdateInfo(m.AutoUpdate, m.ServerVersion)
if a.mode() == proto.TransportLP {
return errors.New("set to long polling")
}
+17 -5
View File
@@ -1,8 +1,8 @@
// Package proto describes the wire messages shared by the server and the agent.
//
// All agent<->server traffic is plain JSON over HTTP(S) long-polling so it
// survives NAT and restrictive egress firewalls: the agent only ever makes
// outbound requests.
// All agent<->server traffic is plain JSON, on a WebSocket the agent opens or,
// where that does not get through, over HTTP(S) long polls, so it survives
// NAT and restrictive egress firewalls: the agent only ever connects out.
package proto
import "strings"
@@ -11,7 +11,7 @@ import "encoding/json"
// Control poll message types (server -> agent, returned from /api/agent/poll).
const (
// CtrlIdle is returned when the long-poll times out with nothing to do.
// CtrlIdle is returned when the control poll times out with nothing to do.
CtrlIdle = "idle"
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
CtrlOpenTerminal = "open_terminal"
@@ -56,7 +56,8 @@ type EnrollResponse struct {
Token string `json:"token"`
}
// ControlMessage is the body returned by the control long-poll.
// ControlMessage is a command for the agent: on the WebSocket, or the body of
// the control long poll.
type ControlMessage struct {
Type string `json:"type"`
SessionID string `json:"session_id,omitempty"`
@@ -102,8 +103,19 @@ type WSMsg struct {
Transport string `json:"transport,omitempty"`
Fallback string `json:"fallback,omitempty"`
Main string `json:"main,omitempty"`
// Auto-update of agents: how often (minutes) an agent compares its
// version with the server's and updates itself if the server's is newer;
// 0 or missing - it does not. ServerVersion is the server's.
AutoUpdate int `json:"autoupdate,omitempty"`
ServerVersion string `json:"server_version,omitempty"`
}
// Headers with the same on the poll answer and on /api/agent/config.
const (
AutoUpdateHeader = "X-Auto-Update" // minutes, "0" or missing: off
ServerVersionHeader = "X-Server-Version" // the server's release version
)
// Terminal / stream frame types on the session "in" channel (server -> agent).
// Reused for both PTY terminals and raw TCP port-forward streams.
const (
+50
View File
@@ -0,0 +1,50 @@
package proto
import (
"strconv"
"strings"
)
// versionParts reads "0.2.5" (a leading v and a "-suffix" are allowed) as
// numbers; ok is false for anything that is not a dotted number.
func versionParts(v string) (parts []int, ok bool) {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexAny(v, "-+"); i >= 0 {
v = v[:i]
}
if v == "" {
return nil, false
}
for _, p := range strings.Split(v, ".") {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return nil, false
}
parts = append(parts, n)
}
return parts, true
}
// VersionNewer reports whether version a is higher than b. Anything that is
// not a dotted number (a build named "dev", an empty string) is never newer
// and never older - so a program is never "updated" down, or from a guess.
func VersionNewer(a, b string) bool {
pa, okA := versionParts(a)
pb, okB := versionParts(b)
if !okA || !okB {
return false
}
for i := 0; i < len(pa) || i < len(pb); i++ {
var x, y int
if i < len(pa) {
x = pa[i]
}
if i < len(pb) {
y = pb[i]
}
if x != y {
return x > y
}
}
return false
}
+30
View File
@@ -0,0 +1,30 @@
package proto
import "testing"
// A version is newer only when it is a higher dotted number: never for the
// same, an older one, or what is not a version at all.
func TestVersionNewer(t *testing.T) {
for _, c := range []struct {
a, b string
want bool
}{
{"0.2.5", "0.2.4", true},
{"0.2.10", "0.2.9", true},
{"0.3.0", "0.2.99", true},
{"1.0", "0.9.9", true},
{"0.2.4", "0.2.4", false},
{"0.2.3", "0.2.4", false},
{"0.2", "0.2.0", false},
{"v0.2.5", "0.2.4", true},
{"", "0.2.4", false},
{"dev", "0.2.4", false},
{"0.2.5", "dev", false},
{"0.2.5", "", false},
{"0.2.5-rc1", "0.2.4", true},
} {
if got := VersionNewer(c.a, c.b); got != c.want {
t.Errorf("VersionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
+368 -45
View File
@@ -27,6 +27,9 @@ import (
// and never sent to the browser - and streams the answer back as text.
const (
// cfgAIModels: the models to choose from, as JSON - the first is the
// default. The four keys after it are the one model of before.
cfgAIModels = "ai_models"
cfgAIType = "ai_type" // gradio, openai or anthropic
cfgAIURL = "ai_url"
cfgAIKey = "ai_key"
@@ -36,11 +39,69 @@ const (
DefaultAIURL = "https://agentproxy.minomix.ru"
)
// aiNativeKey is the config key of what was learned of a model: "1" it
// thinks by itself, "0" it does not, missing - not known yet.
func aiNativeKey(id string) string { return "ai_native_" + id }
// 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 }
// aiOn: the user has the assistant - on unless turned off.
func (s *Server) aiOn(id int64) bool {
v, _ := s.db.GetConfig(aiUserKey(id))
return v != "0"
}
// aiConfig is one model the assistant can ask: its id and the names the chat
// shows (Short, up to 12 characters, on the button of the model in use), the
// kind of API, its address, model and key.
type aiConfig struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Short string `json:"short,omitempty"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Key string `json:"key,omitempty"`
Model string `json:"model,omitempty"`
// think, for one request: "on" asks for the model's own thinking, "off"
// asks it not to think, "" leaves it to the model.
think string
}
// label is the model's name in the chat: its own, else its model or kind.
func (c aiConfig) label() string {
switch {
case c.Name != "":
return c.Name
case c.Model != "":
return c.Model
case c.Type == "openai":
return "OpenAI"
case c.Type == "anthropic":
return "Anthropic"
}
return "Qwen Omni"
}
// tidy keeps a known kind and gives gradio its default address.
func (c aiConfig) tidy() aiConfig {
if c.Type != "openai" && c.Type != "anthropic" {
c.Type = "gradio"
}
c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key)
if r := []rune(strings.TrimSpace(c.Short)); len(r) > 12 {
c.Short = strings.TrimSpace(string(r[:12]))
} else {
c.Short = string(r)
}
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
if c.URL == "" && c.Type == "gradio" {
c.URL = DefaultAIURL
}
return c
}
type aiAttachment struct {
Name string `json:"name"`
@@ -57,15 +118,36 @@ type aiMsg struct {
ThinkMS int64 `json:"think_ms,omitempty"`
}
func (s *Server) aiConfig() aiConfig {
get := func(k, def string) string {
if v, _ := s.db.GetConfig(k); v != "" {
return v
// aiModels are the models set in Настройки, never none: before any is
// saved, the one of the older settings (or the default).
func (s *Server) aiModels() []aiConfig {
var ms []aiConfig
if v, _ := s.db.GetConfig(cfgAIModels); v != "" {
_ = json.Unmarshal([]byte(v), &ms)
}
return def
if len(ms) == 0 {
get := func(k string) string { v, _ := s.db.GetConfig(k); return v }
ms = []aiConfig{{ID: "1", Type: get(cfgAIType), URL: get(cfgAIURL), Key: get(cfgAIKey), Model: get(cfgAIModel)}}
}
return aiConfig{Type: get(cfgAIType, "gradio"), URL: strings.TrimRight(get(cfgAIURL, DefaultAIURL), "/"), Key: get(cfgAIKey, ""), Model: get(cfgAIModel, "")}
for i := range ms {
ms[i] = ms[i].tidy()
}
return ms
}
// aiModel is the model of that id, or the first.
func (s *Server) aiModel(id string) aiConfig {
ms := s.aiModels()
for _, m := range ms {
if m.ID == id {
return m
}
}
return ms[0]
}
// aiConfig is the default model, the first.
func (s *Server) aiConfig() aiConfig { return s.aiModel("") }
var aiClient = &http.Client{Timeout: 10 * time.Minute}
@@ -100,6 +182,59 @@ func sseData(r io.Reader, fn func(data string) (stop bool, err error)) error {
return sc.Err()
}
// aiHTTPError is a model's answer other than 2xx.
type aiHTTPError struct {
Code int
Msg string
}
func (e aiHTTPError) Error() string {
return fmt.Sprintf("модель ответила %d: %s", e.Code, e.Msg)
}
// badRequest: the model did not take the request as it was (an option it
// does not know, say).
func badRequest(err error) bool {
var e aiHTTPError
return errors.As(err, &e) && (e.Code == http.StatusBadRequest || e.Code == http.StatusUnprocessableEntity)
}
// thinkOpen and thinkClose frame a model's own thinking in the answer, as
// the chat shows thinking (and as a model asked to think in words writes it).
const (
thinkOpen = "::думаю::\n"
thinkClose = "\n::ответ::\n"
)
// thinkWrap passes on a model's thinking and answer as they come, the
// thinking framed by thinkOpen and thinkClose.
type thinkWrap struct {
emit func(string)
state int // 0: nothing yet, 1: thinking, 2: answering
}
func (w *thinkWrap) think(t string) {
if t == "" || w.state == 2 {
return
}
if w.state == 0 {
w.emit(thinkOpen)
w.state = 1
}
w.emit(t)
}
func (w *thinkWrap) text(t string) {
if t == "" {
return
}
if w.state == 1 {
w.emit(thinkClose)
}
w.state = 2
w.emit(t)
}
func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*http.Response, error) {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
@@ -117,7 +252,7 @@ func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
resp.Body.Close()
return nil, fmt.Errorf("модель ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
return nil, aiHTTPError{resp.StatusCode, strings.TrimSpace(string(msg))}
}
return resp, nil
}
@@ -158,10 +293,21 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
if cfg.Key != "" {
hdr["Authorization"] = "Bearer " + cfg.Key
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}, hdr)
// Thinking on or off by the chat template's switch (llama.cpp, vLLM,
// Ollama…); a server that does not know it is asked again without.
body := map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}
if cfg.think != "" {
body["chat_template_kwargs"] = map[string]any{"enable_thinking": cfg.think == "on"}
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
if err != nil && cfg.think != "" && badRequest(err) {
delete(body, "chat_template_kwargs")
resp, err = aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
defer resp.Body.Close()
return sseData(resp.Body, func(d string) (bool, error) {
if d == "[DONE]" {
@@ -171,6 +317,8 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
@@ -184,9 +332,8 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return true, errors.New(m.Error.Message)
}
for _, c := range m.Choices {
if c.Delta.Content != "" {
emit(c.Delta.Content)
}
tw.think(c.Delta.ReasoningContent + c.Delta.Reasoning)
tw.text(c.Delta.Content)
}
return false, nil
})
@@ -212,17 +359,32 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
}
apiMessages = append(apiMessages, map[string]any{"role": m.Role, "content": content})
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true},
map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"})
body := map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true}
hdr := map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"}
// Extended thinking, for «Думать»; a model without it is asked again
// as it was.
if cfg.think == "on" {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 4096}
body["max_tokens"] = 12288
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
if err != nil && cfg.think == "on" && badRequest(err) {
delete(body, "thinking")
body["max_tokens"] = 4096
resp, err = aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
defer resp.Body.Close()
return sseData(resp.Body, func(d string) (bool, error) {
var m struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"delta"`
Error struct {
Message string `json:"message"`
@@ -233,7 +395,11 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
}
switch m.Type {
case "content_block_delta":
emit(m.Delta.Text)
if m.Delta.Type == "thinking_delta" {
tw.think(m.Delta.Thinking)
} else {
tw.text(m.Delta.Text)
}
case "message_stop":
return true, nil
case "error":
@@ -314,10 +480,15 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return err
}
var conf struct {
Components []struct {
ID int `json:"id"`
Type string `json:"type"`
} `json:"components"`
Dependencies []struct {
ID int `json:"id"`
APIName string `json:"api_name"`
Targets [][]any `json:"targets"`
Inputs []int `json:"inputs"`
} `json:"dependencies"`
}
err = json.NewDecoder(resp.Body).Decode(&conf)
@@ -326,45 +497,79 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return errors.New("модель: не прочиталась её конфигурация")
}
fn, trigger := -1, any(nil)
var inputs []int
for _, d := range conf.Dependencies {
if d.APIName == "chat_predict" {
fn = d.ID
inputs = d.Inputs
if len(d.Targets) > 0 && len(d.Targets[0]) > 0 {
trigger = d.Targets[0][0]
}
}
}
// Its inputs in order, by kind - the files go where the app has them.
// An app that does not say: Qwen Omni's order.
kinds := []string{"textbox", "audio", "image", "video", "chatbot", "textbox", "slider", "slider", "slider"}
if len(inputs) > 0 {
types := map[int]string{}
for _, c := range conf.Components {
types[c.ID] = c.Type
}
kinds = kinds[:0:0]
for _, id := range inputs {
kinds = append(kinds, types[id])
}
}
if fn < 0 {
return errors.New("модель: нет функции chat_predict")
}
last := msgs[len(msgs)-1]
media := []any{nil, nil, nil}
media := map[string]any{}
for _, a := range last.Attachments {
if a.Data == "" {
kind := strings.SplitN(a.MIME, "/", 2)[0]
if a.Data == "" || (kind != "image" && kind != "audio" && kind != "video") {
continue
}
slot := -1
switch {
case strings.HasPrefix(a.MIME, "image/"):
slot = 0
case strings.HasPrefix(a.MIME, "audio/"):
slot = 1
case strings.HasPrefix(a.MIME, "video/"):
slot = 2
}
if slot >= 0 {
fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
if err != nil {
return err
}
media[slot] = fileData
if kind == "video" {
// Gradio 5 takes a video with its subtitles.
media[kind] = map[string]any{"video": fileData, "subtitles": nil}
} else {
media[kind] = fileData
}
}
// The values in the inputs' order: the question in the first text box,
// the system prompt in the second, the talk in the chatbot, the files,
// and temperature, top p and top k in the sliders.
data := make([]any, len(kinds))
texts, sliders := 0, []any{0.7, 0.8, 20}
for i, k := range kinds {
switch k {
case "textbox":
if texts == 0 {
data[i] = last.Content
} else {
data[i] = system
}
texts++
case "image", "audio", "video":
data[i] = media[k]
case "chatbot":
data[i] = msgs[:len(msgs)-1]
case "slider":
if len(sliders) > 0 {
data[i], sliders = sliders[0], sliders[1:]
}
}
}
b := make([]byte, 8)
_, _ = rand.Read(b)
session := hex.EncodeToString(b)
jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{
"data": []any{last.Content, media[0], media[1], media[2], msgs[:len(msgs)-1], system, 0.7, 0.8, 20},
"data": data,
"fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session,
}, hdr)
if err != nil {
@@ -558,6 +763,7 @@ func (s *Server) aiDeleteChat(owner, chat int64) {
}
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner)
_, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner)
s.db.wipeDeleted()
}
// aiAppendTo keeps a message of owner's chat; the first question names it.
@@ -576,6 +782,7 @@ func (s *Server) aiKeepMsg(owner, chat int64, m aiMsg) {
now := time.Now().Unix()
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
s.db.wipeDeleted()
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
if role == "user" {
t := strings.Join(strings.Fields(content), " ")
@@ -634,7 +841,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 == "0" {
if !s.aiOn(a.ID) {
http.Error(w, "ассистент выключен в профиле", http.StatusForbidden)
return
}
@@ -647,6 +854,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
Search bool `json:"search"`
Chat int64 `json:"chat"`
Attachments []aiAttachment `json:"attachments"`
Model string `json:"model"` // the model's id; "" or unknown: the first
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 24<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "" && len(in.Attachments) == 0) {
http.Error(w, "плохой запрос", http.StatusBadRequest)
@@ -656,7 +864,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
http.Error(w, "можно прикрепить не больше 6 файлов", http.StatusBadRequest)
return
}
cfg := s.aiConfig()
cfg := s.aiModel(in.Model)
total := 0
mediaCounts := map[string]int{}
for i := range in.Attachments {
@@ -722,7 +930,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
s.aiAppendTo(a.ID, in.Chat, "user", stored)
if fresh {
// A new chat gets a short name from the model, aside.
go s.aiNameChat(a.ID, in.Chat, stored)
go s.aiNameChat(cfg, a.ID, in.Chat, stored)
}
msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000)
if len(msgs) > 0 && msgs[len(msgs)-1].Role == "user" {
@@ -732,19 +940,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
}
system := aiSystem
// Pictures, sound and video go to the model itself: it is told so, or
// it takes them for something a panel assistant does not do.
var media []string
for _, f := range in.Attachments {
if f.Data != "" {
media = append(media, f.Name)
}
}
if len(media) > 0 {
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
}
system += s.imagePrompt()
if in.Agent {
system += aiAgent
}
if in.Search {
system += searchPrompt(s.searchConf())
}
if in.Think {
// «Думать»: a model with thinking of its own (OpenAI-compatible or
// Anthropic) thinks by itself, once; the others are asked to think in
// words. Which it is, is learned from its answers and kept. Without
// «Думать» a model that would think anyway is asked not to.
native := ""
if cfg.Type == "openai" || cfg.Type == "anthropic" {
native, _ = s.db.GetConfig(aiNativeKey(cfg.ID))
}
ownThink := in.Think && (cfg.Type == "openai" || cfg.Type == "anthropic") && native != "0"
promptThink := func() {
system += aiThink
// Models heed the last words most: the reminder goes there too (not
// kept with the talk).
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)"
}
if cfg.Type == "openai" {
cfg.think = "off"
} else {
cfg.think = ""
}
}
switch {
case ownThink:
cfg.think = "on"
case in.Think:
promptThink()
case cfg.Type == "openai":
cfg.think = "off"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -767,7 +1010,40 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
fl.Flush()
}
}
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
run := func(ctx context.Context, emit func(string)) error {
return aiStream(ctx, cfg, system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
}
var err error
if ownThink && native == "" {
// Not known yet: its first words tell. Thinking - it has its own;
// an answer straight away - it has not, and that answer is dropped
// unseen for one asked to think in words.
ctx, cancel := context.WithCancel(r.Context())
first, dropped := true, false
err = run(ctx, func(t string) {
if dropped || t == "" {
return
}
if first {
first = false
if t != thinkOpen {
dropped = true
cancel()
return
}
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "1")
}
emit(t)
})
cancel()
if dropped {
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "0")
promptThink()
err = run(r.Context(), emit)
}
} else {
err = run(r.Context(), emit)
}
// The answer is kept whole, thinking too (to show it again); the model
// gets it without (aiWindow).
ans := aiMsg{Role: "assistant", Content: answer.String()}
@@ -788,14 +1064,17 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
// aiNameChat asks the model for a two-or-three-word name of the chat its
// first question opened, and gives the chat that name.
func (s *Server) aiNameChat(owner, chat int64, question string) {
func (s *Server) aiNameChat(cfg aiConfig, owner, chat int64, question string) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if r := []rune(question); len(r) > 600 {
question = string(r[:600])
}
var b strings.Builder
err := aiStream(ctx, s.aiConfig(), "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
if cfg.Type == "openai" {
cfg.think = "off" // a name needs no thinking
}
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
if err != nil {
log.Printf("ai: naming chat %d: %v", chat, err)
@@ -866,7 +1145,18 @@ 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 != "0"})
// The models to choose from: ids and names, never a key.
type pick struct {
ID string `json:"id"`
Name string `json:"name"`
Short string `json:"short,omitempty"`
}
var ms []pick
for _, m := range s.aiModels() {
ms = append(ms, pick{m.ID, m.label(), m.Short})
}
_, imgKey := s.imageConf()
writeJSON(w, map[string]any{"on": on != "0", "models": ms, "image": imgKey != ""})
}
// POST /api/panel/ai/toggle - {on}: the user's own switch.
@@ -893,15 +1183,39 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
typ := r.FormValue("ai_type")
if typ != "openai" && typ != "anthropic" {
typ = "gradio"
// The models, a card each, in order. The key field shows the key there;
// what it holds is the key (empty: none). A card left empty is dropped;
// a new one gets an id.
f := r.PostForm
ids, names, shorts, types, urls, models, keys := f["m_id"], f["m_name"], f["m_short"], f["m_type"], f["m_url"], f["m_model"], f["m_key"]
at := func(v []string, i int) string {
if i < len(v) {
return v[i]
}
return ""
}
var ms []aiConfig
seen := map[string]bool{}
for i := range types {
m := aiConfig{ID: at(ids, i), Name: at(names, i), Short: at(shorts, i), Type: at(types, i), URL: at(urls, i), Model: at(models, i), Key: at(keys, i)}
if strings.TrimSpace(m.Name+m.Short+m.Type+m.URL+m.Model+m.Key) == "" {
continue
}
if m.ID == "" || seen[m.ID] || len(m.ID) > 32 {
m.ID = newAIModelID()
}
seen[m.ID] = true
ms = append(ms, m.tidy())
}
if len(ms) > 0 {
b, _ := json.Marshal(ms)
_ = s.db.SetConfig(cfgAIModels, string(b))
// A model saved may be another one now: whether it thinks by itself
// is learned again.
for _, m := range ms {
_ = s.db.SetConfig(aiNativeKey(m.ID), "")
}
}
_ = s.db.SetConfig(cfgAIType, typ)
_ = s.db.SetConfig(cfgAIURL, strings.TrimSpace(r.FormValue("ai_url")))
_ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model")))
// The field shows the key there; what it holds is the key (empty: none).
_ = s.db.SetConfig(cfgAIKey, strings.TrimSpace(r.FormValue("ai_key")))
// The search engine for «Поиск».
eng := r.FormValue("ai_search")
if eng != "searxng" && eng != "brave" && eng != "tavily" {
@@ -910,6 +1224,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
_ = s.db.SetConfig(cfgSearchEngine, eng)
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
// The key of the image service («Генерация изображений»).
_ = s.db.SetConfig(cfgImageKey, strings.TrimSpace(r.FormValue("ai_image_key")))
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
}
@@ -1001,3 +1317,10 @@ func stripThink(s string) string {
}
return s
}
// newAIModelID is a short random id for a model saved for the first time.
func newAIModelID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+40
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -393,3 +394,42 @@ func TestAIChatTitle(t *testing.T) {
}
t.Fatalf("title: %+v", s.aiChats(a.ID))
}
// Qwen Omni's files go where its inputs are (audio before image there), and
// a video as Gradio 5 takes one: {video, subtitles}.
func TestAIGradioMediaSlots(t *testing.T) {
var joined map[string]any
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/config":
fmt.Fprint(w, `{"components":[{"id":20,"type":"textbox"},{"id":16,"type":"audio"},{"id":17,"type":"image"},{"id":18,"type":"video"},`+
`{"id":13,"type":"chatbot"},{"id":7,"type":"textbox"},{"id":8,"type":"slider"},{"id":9,"type":"slider"},{"id":10,"type":"slider"}],`+
`"dependencies":[{"id":0,"api_name":"chat_predict","targets":[[21,"click"]],"inputs":[20,16,17,18,13,7,8,9,10]}]}`)
case "/gradio_api/upload":
n++
fmt.Fprintf(w, `["/tmp/f%d"]`, n)
case "/gradio_api/queue/join":
_ = json.NewDecoder(r.Body).Decode(&joined)
fmt.Fprint(w, `{"event_id":"e"}`)
case "/gradio_api/queue/data":
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":true,\"output\":{\"data\":[{},{},{},{},[{\"role\":\"assistant\",\"content\":\"ok\"}],{},{}]}}\n\n")
}
}))
defer srv.Close()
enc := base64.StdEncoding.EncodeToString([]byte("x"))
msgs := []aiMsg{{Role: "user", Content: "что тут", Attachments: []aiAttachment{
{Name: "a.png", MIME: "image/png", Data: enc},
{Name: "b.mp3", MIME: "audio/mpeg", Data: enc},
{Name: "c.mp4", MIME: "video/mp4", Data: enc},
}}}
if err := aiStream(context.Background(), aiConfig{Type: "gradio", URL: srv.URL}, "sys", msgs, func(string) {}); err != nil {
t.Fatal(err)
}
d := joined["data"].([]any)
path := func(v any) string { m, _ := v.(map[string]any); p, _ := m["path"].(string); return p }
video, _ := d[3].(map[string]any)
if path(d[1]) != "/tmp/f2" || path(d[2]) != "/tmp/f1" || path(video["video"]) != "/tmp/f3" || video["subtitles"] != nil || d[5] != "sys" {
t.Fatalf("data: %v", d)
}
}
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// The model set before there could be several is the first of the list.
func TestAIModelsFromLegacy(t *testing.T) {
s, _ := newTestServer(t)
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, "https://api.example/v1/")
_ = s.db.SetConfig(cfgAIModel, "gpt-x")
_ = s.db.SetConfig(cfgAIKey, "k1")
ms := s.aiModels()
if len(ms) != 1 || ms[0].Type != "openai" || ms[0].URL != "https://api.example/v1" || ms[0].Model != "gpt-x" || ms[0].Key != "k1" || ms[0].ID == "" {
t.Fatalf("models: %+v", ms)
}
if got := s.aiModel("nope"); got.ID != ms[0].ID {
t.Fatalf("an unknown id is not the first model: %+v", got)
}
}
// postAIModels sends the settings form with the given models.
func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][7]string) {
t.Helper()
f := url.Values{"csrf": {"tok"}}
for _, m := range models {
f.Add("m_id", m[0])
f.Add("m_name", m[1])
f.Add("m_type", m[2])
f.Add("m_url", m[3])
f.Add("m_model", m[4])
f.Add("m_key", m[5])
f.Add("m_short", m[6])
}
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
}
// Настройки keep several models with their names; the chat learns the
// names and ids only, never a key.
func TestAIModelsSaveAndList(t *testing.T) {
s, c := newTestServer(t)
postAIModels(t, s, c, [][7]string{
{"", "Qwen", "gradio", "", "", "", ""},
{"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", "Сонет 4.5 длинно"},
{"", "", "", "", "", "", ""}, // an empty card is dropped
})
ms := s.aiModels()
if len(ms) != 2 || ms[0].Name != "Qwen" || ms[1].Name != "Claude" || ms[1].Key != "secret-key" || ms[0].ID == "" || ms[0].ID == ms[1].ID || ms[1].Short != "Сонет 4.5 дл" {
t.Fatalf("models: %+v", ms)
}
body := get(t, s, c, "/api/panel/ai").Body.String()
if strings.Contains(body, "secret-key") || !strings.Contains(body, `"name":"Claude"`) || !strings.Contains(body, `"short":"Сонет 4.5 дл"`) || !strings.Contains(body, ms[1].ID) {
t.Fatalf("state: %s", body)
}
// Saved again with its id, a model keeps it.
postAIModels(t, s, c, [][7]string{{ms[1].ID, "Claude 2", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", ""}})
if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" {
t.Fatalf("after resave: %+v", got)
}
if !strings.Contains(get(t, s, c, "/settings?frag=1").Body.String(), `value="Claude 2"`) {
t.Fatal("the settings page does not show the model's name")
}
}
// The chat asks the model it names.
func TestAIChatUsesChosenModel(t *testing.T) {
s, c := newTestServer(t)
srv := func(word string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}]}\n\ndata: [DONE]\n\n", word)
}))
}
one, two := srv("first"), srv("second")
defer one.Close()
defer two.Close()
postAIModels(t, s, c, [][7]string{
{"", "One", "openai", one.URL, "m", "", ""},
{"", "Two", "openai", two.URL, "m", "", ""},
})
ms := s.aiModels()
ask := func(model string) string {
b, _ := json.Marshal(map[string]any{"message": "hi", "context": "x", "model": model})
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(string(b)))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := ask(ms[1].ID); !strings.Contains(got, "second") {
t.Fatalf("model Two: %q", got)
}
if got := ask(""); !strings.Contains(got, "first") {
t.Fatalf("no model named - the first: %q", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The assistant is on unless the user turned it off - for the web search
// and page reading as for the chat (a user who never touched the switch
// has no setting at all).
func TestAISearchAndFetchOnByDefault(t *testing.T) {
s, c := newTestServer(t)
sx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"results":[{"title":"Intel","url":"https://example.com/intel","content":"процессоры"}]}`)
}))
defer sx.Close()
_ = s.db.SetConfig(cfgSearchEngine, "searxng")
_ = s.db.SetConfig(cfgSearchURL, sx.URL)
post := func(path, body string) string {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := post("/api/panel/ai/search", `{"q":"intel"}`); !strings.Contains(got, `"ok":true`) {
t.Fatalf("search by default: %s", got)
}
if got := post("/api/panel/ai/fetch", `{"url":"не адрес"}`); strings.Contains(got, "выключен") {
t.Fatalf("fetch by default: %s", got)
}
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
for _, p := range []string{"/api/panel/ai/search", "/api/panel/ai/fetch"} {
if got := post(p, `{"q":"intel","url":"https://example.com"}`); !strings.Contains(got, "выключен") {
t.Fatalf("%s with the assistant off: %s", p, got)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// openAIChunk is one streamed delta of an OpenAI-compatible server.
func openAIChunk(field, text string) string {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{field: text}}}})
return "data: " + string(b) + "\n\n"
}
// A model that thinks by itself (llama.cpp, vLLM: reasoning_content) has its
// thinking shown as the chat's own: between ::думаю:: and ::ответ::.
func TestAIOpenAINativeReasoning(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, openAIChunk("reasoning_content", "17*23")+openAIChunk("reasoning_content", " = 391")+
openAIChunk("content", "391")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "on"})
if err != nil || got != "::думаю::\n17*23 = 391\n::ответ::\n391" {
t.Fatalf("got %q, %v", got, err)
}
kw, _ := body["chat_template_kwargs"].(map[string]any)
if kw["enable_thinking"] != true {
t.Fatalf("thinking not asked for: %v", body)
}
}
// A server that does not know the thinking switch (400 on it) is asked again
// without it.
func TestAIOpenAIRetriesWithoutThinkSwitch(t *testing.T) {
var mu sync.Mutex
var calls []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
mu.Lock()
calls = append(calls, b)
mu.Unlock()
if _, ok := b["chat_template_kwargs"]; ok {
http.Error(w, `{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`, 400)
return
}
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "off"})
if err != nil || got != "ok" || len(calls) != 2 {
t.Fatalf("got %q, %v, %d calls", got, err, len(calls))
}
}
// Anthropic: «Думать» turns on extended thinking; its thinking_delta is the
// thinking shown.
func TestAIAnthropicThinking(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"хм\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Да\"}}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m", think: "on"})
if err != nil || got != "::думаю::\nхм\n::ответ::\nДа" {
t.Fatalf("got %q, %v", got, err)
}
th, _ := body["thinking"].(map[string]any)
if th["type"] != "enabled" || body["max_tokens"].(float64) <= th["budget_tokens"].(float64) {
t.Fatalf("thinking not asked for: %v", body)
}
}
// askThink sends one «Думать» question to the chat and gives the answer and
// the system prompts the model got, in order.
func askThink(t *testing.T, reply func(system string, body map[string]any) string) (string, []string) {
t.Helper()
s, c := newTestServer(t)
var mu sync.Mutex
var systems []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
sys := b["messages"].([]any)[0].(map[string]any)["content"].(string)
if strings.Contains(fmt.Sprint(b["messages"]), "название чата") || strings.Contains(sys, "название чата") {
fmt.Fprint(w, openAIChunk("content", "Чат")+"data: [DONE]\n\n")
return
}
mu.Lock()
systems = append(systems, sys)
mu.Unlock()
fmt.Fprint(w, reply(sys, b)+"data: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"сколько 17*23","context":"x","think":true}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
mu.Lock()
defer mu.Unlock()
return w.Body.String(), append([]string(nil), systems...)
}
// A model with thinking of its own thinks once, by itself: no "think in
// words" prompt on top.
func TestAIChatThinkNative(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
return openAIChunk("reasoning_content", "считаю") + openAIChunk("content", "391")
})
if len(systems) != 1 || strings.Contains(systems[0], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if !strings.Contains(got, "::думаю::\nсчитаю\n::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}
// A model without it is asked again with the prompt, its first answer
// dropped unseen.
func TestAIChatThinkPromptFallback(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
if strings.Contains(sys, "Режим размышления") {
return openAIChunk("content", "::думаю::\nсчитаю\n::ответ::\n391")
}
return openAIChunk("content", "сразу ответ")
})
if len(systems) != 2 || strings.Contains(systems[0], "Режим размышления") || !strings.Contains(systems[1], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if strings.Contains(got, "сразу ответ") || !strings.Contains(got, "::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}
+389
View File
@@ -0,0 +1,389 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Auto-update, two kinds, each off until asked for (Настройки › Автообновление):
// - of the agents: they are told how often to compare their version with
// the server's and, if the server's is newer, to take the agent from it
// (as the panel's «Обновить» does);
// - of the server: it compares itself with the latest release on GitLab;
// when that is newer, an administrator is offered it at login (after the
// PIN), and on «Обновить» the install script is run (the same one as
// `wget -qO- …/server-install.sh | sudo sh`). Nothing runs unasked.
const (
cfgAgentAuto = "agent_autoupdate"
cfgAgentAutoMin = "agent_autoupdate_min"
cfgServerAuto = "server_autoupdate"
cfgServerAutoMin = "server_autoupdate_min"
// Where releases are. Not in the settings: for tests.
cfgUpdateRepo = "update_repo"
cfgUpdateBase = "update_base"
DefaultUpdateRepo = "itzredtea/lpra"
DefaultUpdateBase = "https://gitlab.com"
defaultAutoMin = 360 // 6 hours
maxAutoMin = 60 * 24 * 30 // 30 days
)
// autoSetting reads an "on" flag and its interval in minutes.
func (s *Server) autoSetting(onKey, minKey string) (bool, int) {
on, _ := s.db.GetConfig(onKey)
m, _ := strconv.Atoi(func() string { v, _ := s.db.GetConfig(minKey); return v }())
if m < 1 || m > maxAutoMin {
m = defaultAutoMin
}
return on == "1", m
}
func (s *Server) agentAuto() (bool, int) { return s.autoSetting(cfgAgentAuto, cfgAgentAutoMin) }
func (s *Server) serverAuto() (bool, int) { return s.autoSetting(cfgServerAuto, cfgServerAutoMin) }
// setUpdateHeaders tells an agent the server's version and how often to
// compare ("0": never).
func (s *Server) setUpdateHeaders(w http.ResponseWriter) {
w.Header().Set(proto.ServerVersionHeader, Version)
w.Header().Set(proto.AutoUpdateHeader, strconv.Itoa(s.agentAutoMinutes()))
}
// agentAutoMinutes is the interval agents are given, 0 when off.
func (s *Server) agentAutoMinutes() int {
if on, m := s.agentAuto(); on {
return m
}
return 0
}
// minutesOf turns "2" and "h" into 120: min, h, d.
func minutesOf(n, unit string) int {
v, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil || v < 1 {
return defaultAutoMin
}
switch unit {
case "h":
v *= 60
case "d":
v *= 60 * 24
}
if v > maxAutoMin {
v = maxAutoMin
}
return v
}
// POST /settings/autoupdate - both settings.
func (s *Server) handleSetAutoUpdate(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
flag := func(v string) string {
if v == "1" {
return "1"
}
return ""
}
_ = s.db.SetConfig(cfgAgentAuto, flag(r.FormValue("agent_auto")))
_ = s.db.SetConfig(cfgAgentAutoMin, strconv.Itoa(minutesOf(r.FormValue("agent_every"), r.FormValue("agent_unit"))))
_ = s.db.SetConfig(cfgServerAuto, flag(r.FormValue("server_auto")))
_ = s.db.SetConfig(cfgServerAutoMin, strconv.Itoa(minutesOf(r.FormValue("server_every"), r.FormValue("server_unit"))))
// A server that was told to look again looks at once.
s.upd.mu.Lock()
s.upd.last = time.Time{}
s.upd.mu.Unlock()
http.Redirect(w, r, "/settings#autoupdate", http.StatusSeeOther)
}
// intervalParts is a number of minutes as the form shows it: 120 - 2 h.
func intervalParts(min int) (int, string) {
switch {
case min%(60*24) == 0:
return min / (60 * 24), "d"
case min%60 == 0:
return min / 60, "h"
}
return min, "min"
}
// autoView is what Настройки show of the settings.
type autoView struct {
AgentOn, ServerOn bool
AgentEvery, ServerEvery int
AgentUnit, ServerUnit string
Status updStatus
}
func (s *Server) autoView() autoView {
var v autoView
var am, sm int
v.AgentOn, am = s.agentAuto()
v.ServerOn, sm = s.serverAuto()
v.AgentEvery, v.AgentUnit = intervalParts(am)
v.ServerEvery, v.ServerUnit = intervalParts(sm)
v.Status = s.updateStatus()
return v
}
// updState is what the updating of the server remembers.
type updState struct {
mu sync.Mutex
last time.Time // when it last looked
latest string
checked time.Time
err string
}
// updStatus is that, for the settings page.
type updStatus struct {
Current, Latest string
Checked string
Error string
Newer bool
}
func (s *Server) updateStatus() updStatus {
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
st := updStatus{Current: Version, Latest: s.upd.latest, Error: s.upd.err, Newer: proto.VersionNewer(s.upd.latest, Version)}
if !s.upd.checked.IsZero() {
st.Checked = s.upd.checked.Format("02.01.2006 15:04")
}
return st
}
func (s *Server) updateRepoBase() (base, repo string) {
base, _ = s.db.GetConfig(cfgUpdateBase)
if base == "" {
base = DefaultUpdateBase
}
repo, _ = s.db.GetConfig(cfgUpdateRepo)
if repo == "" {
repo = DefaultUpdateRepo
}
return strings.TrimRight(base, "/"), repo
}
var updateClient = &http.Client{Timeout: 30 * time.Second}
// latestRelease is the tag of the project's latest release on GitLab.
func (s *Server) latestRelease(ctx context.Context) (string, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v4/projects/"+url.PathEscape(repo)+"/releases/permalink/latest", nil)
req.Header.Set("Accept", "application/json")
resp, err := updateClient.Do(req)
if err != nil {
return "", errors.New("GitLab не отвечает: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", errors.New("GitLab ответил " + resp.Status)
}
var out struct {
Tag string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil || out.Tag == "" {
return "", errors.New("не прочиталась версия последнего релиза")
}
return strings.TrimPrefix(out.Tag, "v"), nil
}
// checkRelease looks at GitLab and keeps what it found for the page.
func (s *Server) checkRelease(ctx context.Context) (string, error) {
tag, err := s.latestRelease(ctx)
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
s.upd.checked = time.Now()
if err != nil {
s.upd.err = err.Error()
return "", err
}
s.upd.latest, s.upd.err = tag, ""
return tag, nil
}
// installScript is the install script on the project's main branch.
func (s *Server) installScript(ctx context.Context) ([]byte, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+repo+"/-/raw/main/server-install.sh", nil)
resp, err := updateClient.Do(req)
if err != nil {
return nil, errors.New("скрипт установки не скачался: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, errors.New("скрипт установки: GitLab ответил " + resp.Status)
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// What is run as root must be the script, not a page of an error.
if !strings.HasPrefix(string(b), "#!/bin/sh\n") || !strings.Contains(string(b), "LPRA server") {
return nil, errors.New("по адресу скрипта установки лежит не он")
}
return b, nil
}
// startServerUpdate takes the install script and has it run, apart from
// this process: it stops the service, and a script in the service's own
// control group would go with it.
func (s *Server) startServerUpdate(ctx context.Context) error {
script, err := s.installScript(ctx)
if err != nil {
return err
}
run := s.updRun
if run == nil {
run = runServerUpdate
}
return run(script)
}
// runServerUpdate runs the script as a transient systemd unit of its own,
// in the folder this server lives in. Needs root and systemd.
func runServerUpdate(script []byte) error {
if os.Geteuid() != 0 {
return errors.New("сервер запущен не от root - обновить его самому нельзя, запустите server-install.sh")
}
sr, err := exec.LookPath("systemd-run")
if err != nil {
return errors.New("нет systemd-run - автообновление сервера работает только под systemd")
}
exe, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exe)
path := filepath.Join(dir, ".server-update.sh")
if err := os.WriteFile(path, script, 0o700); err != nil {
return errors.New("скрипт не записался: " + err.Error())
}
out, err := exec.Command(sr, "--unit=lpra-self-update", "--collect", "--setenv=LPRA_DIR="+dir,
"/bin/sh", "-c", "sh '"+path+"'; rm -f '"+path+"'").CombinedOutput()
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("systemd-run: %s", strings.TrimSpace(string(out)))
}
return nil
}
// autoUpdateTick: one look at the clock. When the server is to look at
// GitLab (on, and the interval is out) it does, and keeps what it found.
func (s *Server) autoUpdateTick(now time.Time) {
on, min := s.serverAuto()
if !on {
return
}
s.upd.mu.Lock()
if !s.upd.last.IsZero() && now.Sub(s.upd.last) < time.Duration(min)*time.Minute {
s.upd.mu.Unlock()
return
}
s.upd.last = now
s.upd.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, _ = s.checkRelease(ctx)
}
// RunAutoUpdate watches the clock until ctx ends.
func (s *Server) RunAutoUpdate(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.autoUpdateTick(now)
}
}
}
// POST /api/panel/update/check - looks at GitLab now.
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "current": Version})
return
}
writeJSON(w, map[string]any{"ok": true, "current": Version, "latest": tag, "newer": proto.VersionNewer(tag, Version)})
}
// POST /api/panel/update/run - updates the server now, to a newer release.
func (s *Server) handleUpdateRun(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
if !proto.VersionNewer(tag, Version) {
writeJSON(w, map[string]any{"ok": false, "error": "уже последняя версия (" + Version + ")"})
return
}
if err := s.startServerUpdate(r.Context()); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "latest": tag})
}
func declinedKey(id int64) string { return "update_declined_" + strconv.FormatInt(id, 10) }
// GET /api/panel/update/offer - whether this administrator is to be offered
// an update now: auto-update on, a release newer than this one, and not one
// they declined.
func (s *Server) handleUpdateOffer(w http.ResponseWriter, r *http.Request) {
no := func() { writeJSON(w, map[string]any{"offer": false}) }
on, min := s.serverAuto()
if !on {
no()
return
}
// Not looked at lately (just started, or the interval is out): look now.
s.upd.mu.Lock()
stale := s.upd.checked.IsZero() || time.Since(s.upd.checked) >= time.Duration(min)*time.Minute
s.upd.mu.Unlock()
if stale {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_, _ = s.checkRelease(ctx)
cancel()
}
st := s.updateStatus()
declined, _ := s.db.GetConfig(declinedKey(s.currentAdmin(r).ID))
if !st.Newer || declined == st.Latest {
no()
return
}
writeJSON(w, map[string]any{"offer": true, "current": st.Current, "latest": st.Latest})
}
// POST /api/panel/update/decline - {version}: this administrator is not to
// be asked about that release again.
func (s *Server) handleUpdateDecline(w http.ResponseWriter, r *http.Request) {
var in struct {
Version string `json:"version"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) == nil && len(in.Version) <= 32 {
_ = s.db.SetConfig(declinedKey(s.currentAdmin(r).ID), in.Version)
}
writeJSON(w, map[string]any{"ok": true})
}
+272
View File
@@ -0,0 +1,272 @@
package server
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// fakeGitLab is the place releases come from: the latest release's tag and
// the install script on main.
type fakeGitLab struct {
srv *httptest.Server
mu sync.Mutex
tag string
script string
hits int
}
func newFakeGitLab(t *testing.T, tag string) *fakeGitLab {
g := &fakeGitLab{tag: tag, script: "#!/bin/sh\n# LPRA server: install or update in one command.\necho updating\n"}
g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.hits++
switch r.URL.EscapedPath() {
case "/api/v4/projects/itzredtea%2Flpra/releases/permalink/latest":
// The real one sends the caller on to the release itself.
http.Redirect(w, r, "/api/v4/projects/1/releases/"+g.tag, http.StatusFound)
case "/api/v4/projects/1/releases/" + g.tag:
fmt.Fprintf(w, `{"tag_name":%q}`, g.tag)
case "/itzredtea/lpra/-/raw/main/server-install.sh":
fmt.Fprint(w, g.script)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(g.srv.Close)
return g
}
func useFakeGitLab(s *Server, g *fakeGitLab) {
_ = s.db.SetConfig(cfgUpdateBase, g.srv.URL)
}
// A runner that only notes what it was given, in place of a real update.
type runs struct {
mu sync.Mutex
scripts []string
err error
}
func (r *runs) run(script []byte) error {
r.mu.Lock()
defer r.mu.Unlock()
r.scripts = append(r.scripts, string(script))
return r.err
}
func (r *runs) n() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.scripts) }
// postAuto sends the form of Настройки › Автообновление.
func postAuto(t *testing.T, s *Server, c *http.Cookie, f url.Values) {
t.Helper()
f.Set("csrf", "tok")
req := httptest.NewRequest(http.MethodPost, "/settings/autoupdate", strings.NewReader(f.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
}
// The settings are kept in minutes whatever unit they were given in, and
// off unless asked for.
func TestAutoUpdateSettings(t *testing.T) {
s, c := newTestServer(t)
if on, _ := s.agentAuto(); on {
t.Fatal("agents update by themselves with nothing set")
}
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"2"}, "agent_unit": {"h"}, "server_auto": {"1"}, "server_every": {"1"}, "server_unit": {"d"}})
if on, m := s.agentAuto(); !on || m != 120 {
t.Fatalf("agents: %v %d", on, m)
}
if on, m := s.serverAuto(); !on || m != 1440 {
t.Fatalf("server: %v %d", on, m)
}
// Out of range or not a number: the default, or the largest.
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"x"}, "agent_unit": {"min"}, "server_every": {"999"}, "server_unit": {"d"}})
if _, m := s.agentAuto(); m != defaultAutoMin {
t.Fatalf("a bad number: %d", m)
}
if on, m := s.serverAuto(); on || m != maxAutoMin {
t.Fatalf("server off, a large number: %v %d", on, m)
}
body := get(t, s, c, "/settings?frag=1").Body.String()
if !strings.Contains(body, `id="autoupdate"`) || !strings.Contains(body, "Автообновление клиентов") || !strings.Contains(body, "Автообновление сервера") {
t.Fatal("the settings page has no auto-update card")
}
}
// Agents are told of it - on the config they fetch, and with their poll -
// together with the server's own version.
func TestAgentsToldOfAutoUpdate(t *testing.T) {
s, c := newTestServer(t)
if err := s.db.CreateAgent(Agent{ID: "a2", Name: "n", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: hashToken("tok2")}); err != nil {
t.Fatal(err)
}
cfg := func() http.Header {
req := httptest.NewRequest(http.MethodGet, "/api/agent/config", nil)
req.Header.Set("X-Agent-Id", "a2")
req.Header.Set("Authorization", "Bearer tok2")
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("config: %d", w.Code)
}
return w.Header()
}
h := cfg()
if h.Get(proto.AutoUpdateHeader) != "0" || h.Get(proto.ServerVersionHeader) != Version {
t.Fatalf("off: %v", h)
}
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"90"}, "agent_unit": {"min"}})
if h := cfg(); h.Get(proto.AutoUpdateHeader) != "90" {
t.Fatalf("on: %v", h)
}
}
// The latest release is read from GitLab, through its redirect.
func TestLatestRelease(t *testing.T) {
s, _ := newTestServer(t)
g := newFakeGitLab(t, "9.9.9")
useFakeGitLab(s, g)
tag, err := s.latestRelease(t.Context())
if err != nil || tag != "9.9.9" {
t.Fatalf("latest: %q %v", tag, err)
}
g.srv.Close()
if _, err := s.latestRelease(t.Context()); err == nil {
t.Fatal("no error with GitLab out of reach")
}
}
// With auto-update on the server looks at GitLab by itself, at the interval,
// and only looks: updating is the administrator's to say yes to.
func TestServerAutoUpdateTick(t *testing.T) {
s, c := newTestServer(t)
g := newFakeGitLab(t, "99.0.0")
useFakeGitLab(s, g)
r := &runs{}
s.updRun = r.run
now := time.Now()
s.autoUpdateTick(now)
if g.hits != 0 {
t.Fatalf("off, yet it looked: %d hits", g.hits)
}
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
s.autoUpdateTick(now)
if st := s.updateStatus(); st.Latest != "99.0.0" || !st.Newer {
t.Fatalf("it did not learn of the release: %+v", st)
}
hits := g.hits
s.autoUpdateTick(now.Add(10 * time.Minute))
if g.hits != hits {
t.Fatal("it looked again before the interval was out")
}
s.autoUpdateTick(now.Add(61 * time.Minute))
if g.hits == hits {
t.Fatal("it did not look when the interval was out")
}
if r.n() != 0 {
t.Fatalf("it updated by itself: %d runs", r.n())
}
// GitLab out of reach: told on the page, kept until it answers.
g.srv.Close()
s.autoUpdateTick(now.Add(130 * time.Minute))
if st := s.updateStatus(); st.Error == "" {
t.Fatalf("the failure is not kept: %+v", st)
}
}
// What an administrator is offered at login: a newer release, with
// auto-update on, not one they said no to - and again when a newer one comes.
func TestUpdateOffer(t *testing.T) {
s, c := newTestServer(t)
g := newFakeGitLab(t, "99.0.0")
useFakeGitLab(s, g)
offer := func() string { return apiCall(t, s, c, "GET", "/api/panel/update/offer", "") }
if got := offer(); !strings.Contains(got, `"offer":false`) {
t.Fatalf("auto-update off: %s", got)
}
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
got := offer()
if !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
t.Fatalf("a newer release: %s", got)
}
apiCall(t, s, c, "POST", "/api/panel/update/decline", `{"version":"99.0.0"}`)
if got := offer(); !strings.Contains(got, `"offer":false`) {
t.Fatalf("after saying no: %s", got)
}
g.tag = "99.1.0"
s.upd.mu.Lock()
s.upd.checked = time.Time{}
s.upd.mu.Unlock()
if got := offer(); !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.1.0"`) {
t.Fatalf("a still newer release: %s", got)
}
g.tag = Version
s.upd.mu.Lock()
s.upd.checked = time.Time{}
s.upd.mu.Unlock()
if got := offer(); !strings.Contains(got, `"offer":false`) {
t.Fatalf("nothing newer: %s", got)
}
}
// Anything but the install script is not run: a page of GitLab's, say.
func TestUpdateScriptChecked(t *testing.T) {
s, _ := newTestServer(t)
g := newFakeGitLab(t, "99.0.0")
g.script = "<html>502 Bad Gateway</html>"
useFakeGitLab(s, g)
r := &runs{}
s.updRun = r.run
if err := s.startServerUpdate(t.Context()); err == nil || r.n() != 0 {
t.Fatalf("a page was run as a script: %v, %d runs", err, r.n())
}
}
// From the panel: check now, and update now - only to a newer release.
func TestUpdatePanelButtons(t *testing.T) {
s, c := newTestServer(t)
g := newFakeGitLab(t, "99.0.0")
useFakeGitLab(s, g)
r := &runs{}
s.updRun = r.run
got := apiCall(t, s, c, "POST", "/api/panel/update/check", "")
if !strings.Contains(got, `"newer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
t.Fatalf("check: %s", got)
}
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":true`) || r.n() != 1 {
t.Fatalf("run: %s, %d runs", got, r.n())
}
g.tag = Version
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":false`) || r.n() != 1 {
t.Fatalf("run with nothing newer: %s, %d runs", got, r.n())
}
}
// Not for everybody: only an administrator updates the server.
func TestUpdateOnlyForAdmins(t *testing.T) {
s, c := newTestServer(t)
a, _ := s.db.AdminByUsername("adm")
if a == nil {
t.Fatal("no admin")
}
// The test's user is an administrator; the guard is the route's own.
if got := apiCall(t, s, c, "POST", "/api/panel/update/check", ""); strings.Contains(got, "только для администраторов") {
t.Fatalf("an administrator was refused: %s", got)
}
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// A chat deleted takes with it what the user attached to it: the text of
// its files lives in the messages, and must be gone from the database
// files too - not just from the tables (SQLite leaves deleted rows in its
// free pages and in the write-ahead log).
func TestDeleteChatWipesAttachments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.db")
db, err := OpenDB(path)
if err != nil {
t.Fatal(err)
}
s, err := New(Options{DB: db, DistDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
adm, _ := db.AdminByUsername("adm")
me := adm.ID
const secret = "TOP-SECRET-CONTENT-OF-AN-ATTACHED-FILE-7731"
chat := s.aiNewChat(me)
other := s.aiNewChat(me)
s.aiAppendTo(me, chat, "user", "посмотри\n\n[Вложение: notes.txt]\nСодержимое файла:\n"+secret)
s.aiAppendTo(me, chat, "result", "Картинка готова [картинка: https://s2.x-grk.com/uploads/userphoto/ai/zzz-7731.png]")
s.aiAppendTo(me, other, "user", "другой чат, остаётся")
s.aiDeleteChat(me, chat)
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, chat).Scan(&n)
if n != 0 {
t.Fatalf("%d messages of the deleted chat remain", n)
}
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, other).Scan(&n)
if n != 1 {
t.Fatalf("the other chat has %d messages, want 1", n)
}
for _, f := range []string{path, path + "-wal"} {
b, _ := os.ReadFile(f)
if bytes.Contains(b, []byte(secret)) || bytes.Contains(b, []byte("zzz-7731")) {
t.Fatalf("the deleted chat's content is still in %s", filepath.Base(f))
}
}
}
// The oldest chats go past the limit the same way: nothing of them is kept.
func TestOldChatsWipedToo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.db")
db, err := OpenDB(path)
if err != nil {
t.Fatal(err)
}
s, err := New(Options{DB: db, DistDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
adm, _ := db.AdminByUsername("adm")
me := adm.ID
first := s.aiNewChat(me)
s.aiAppendTo(me, first, "user", "[Вложение: a.txt]\nСодержимое файла:\nOLD-ATTACHMENT-TEXT-5512")
for i := 0; i < aiChatsKeep; i++ {
s.aiNewChat(me)
}
if s.aiOwns(me, first) {
t.Fatal("the oldest chat is kept past the limit")
}
for _, f := range []string{path, path + "-wal"} {
b, _ := os.ReadFile(f)
if bytes.Contains(b, []byte("OLD-ATTACHMENT-TEXT-5512")) {
t.Fatalf("the old chat's content is still in %s", filepath.Base(f))
}
}
}
+13 -1
View File
@@ -20,7 +20,7 @@ type DB struct {
func OpenDB(path string) (*DB, error) {
// _pragma busy_timeout avoids "database is locked" under the concurrent
// long-poll load; foreign_keys keeps the schema honest.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
sdb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
@@ -754,3 +754,15 @@ func boolInt(b bool) int {
}
return 0
}
// wipeDeleted makes what was deleted leave the database's files: with
// secure_delete the freed pages are overwritten, and the write-ahead log,
// which still holds the rows as they were written, is emptied (SQLite only;
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
// among that.
func (db *DB) wipeDeleted() {
if db.sql.pg {
return
}
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
+1 -1
View File
@@ -179,7 +179,7 @@ func pageText(page string) (string, string) {
// 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" {
if !s.aiOn(a.ID) {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
+32 -11
View File
@@ -12,8 +12,9 @@ import (
// forwardManager owns the host-side listeners for port-forward rules. It
// supports three transports:
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
// reaches, highest latency);
// - "http": over the agent's own link to the panel - its WebSocket, or the
// HTTP long polls while there is none (works anywhere nginx reaches,
// highest latency);
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
// - "udp": direct UDP over the tunnel port.
type forwardManager struct {
@@ -103,7 +104,8 @@ func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
at.openStream(rule.ClientPort, conn)
}
// bridgeHTTP wires a connection through the HTTP long-poll hub session.
// bridgeHTTP wires a connection through a hub session: the agent carries it
// over its WebSocket, or by long polls while it has none.
func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
defer m.hub.CloseSession(sess.ID)
@@ -127,20 +129,39 @@ func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
}
}()
for {
select {
case <-sess.Done():
return
case out, ok := <-sess.Out():
if !ok || out.Exit {
return
// write passes one message on; false: the stream is over.
write := func(out proto.OutMessage) bool {
if out.Exit {
return false
}
if out.Data != "" {
b, err := base64.StdEncoding.DecodeString(out.Data)
if err != nil {
return
return false
}
if _, err := conn.Write(b); err != nil {
return false
}
}
return true
}
for {
select {
case out, ok := <-sess.Out():
if !ok || !write(out) {
return
}
case <-sess.Done():
// The agent's end of stream closes the session at once, while
// what it sent before may still be queued (a download read
// faster than the browser takes it): that goes out first.
for {
select {
case out := <-sess.Out():
if !write(out) {
return
}
default:
return
}
}
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"bytes"
"encoding/base64"
"io"
"net"
"testing"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// An http-lp download the agent has finished (its last chunk, then the end)
// reaches the browser whole: the session closing does not cut off what is
// still queued for it.
func TestForwardHTTPDrainsAfterClose(t *testing.T) {
hub := NewHub()
m := &forwardManager{hub: hub}
user, host := net.Pipe()
go m.bridgeHTTP(ForwardRule{AgentID: "a1", ClientPort: 80, Proto: "http"}, host)
var sess *Session
for i := 0; i < 100 && sess == nil; i++ {
hub.mu.Lock()
for _, s := range hub.sessions {
sess = s
}
hub.mu.Unlock()
time.Sleep(10 * time.Millisecond)
}
if sess == nil {
t.Fatal("no session")
}
want := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64 KiB in 64 chunks
go func() {
for i := 0; i < len(want); i += 1024 {
sess.PushOut(proto.OutMessage{Data: base64.StdEncoding.EncodeToString(want[i : i+1024])})
}
sess.PushOut(proto.OutMessage{Exit: true})
sess.close() // as the server does on the agent's end of stream
}()
time.Sleep(50 * time.Millisecond) // all of it queued, the session closed, before the browser reads
_ = user.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(user)
if !bytes.Equal(got, want) {
t.Fatalf("got %d of %d bytes", len(got), len(want))
}
}
+8
View File
@@ -144,6 +144,14 @@ func TestViewPrefs(t *testing.T) {
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) {
t.Fatalf("openHere off not kept: %s", got)
}
post(`{"aiModelPick":"always","aiModel":"a1b2c3"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"aiModelPick":"always"`) || !strings.Contains(got, `"aiModel":"a1b2c3"`) {
t.Fatalf("the model choice not kept: %s", got)
}
post(`{"aiModelPick":"often","aiModel":"<x>"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "aiModel") {
t.Fatalf("an unknown model choice was kept: %s", got)
}
post(`{"openHere":"sometimes"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") {
t.Fatalf("an unknown openHere was kept: %s", got)
+2
View File
@@ -56,6 +56,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a setting changed meanwhile reaches the agent now.
s.setTunnelHeader(w)
s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
writeJSON(w, msg)
}
@@ -83,6 +84,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
return
}
s.setTunnelHeader(w)
s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
w.WriteHeader(http.StatusNoContent)
}
+3 -1
View File
@@ -443,8 +443,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version,
"AI": s.aiConfig(),
"AIModels": s.aiModels(),
"Search": s.searchConf(),
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
"Auto": s.autoView(),
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled,
+3 -2
View File
@@ -7,8 +7,9 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
// It carries three kinds of work to an agent through its single control poll:
// Hub is the in-memory broker between agents (on a WebSocket, or by HTTP
// long polls) and the panel. It carries three kinds of work to an agent
// through its single control channel:
// terminal sessions, port-forward streams, and one-shot tasks (file manager).
// Nothing here is persisted.
type Hub struct {
+302
View File
@@ -0,0 +1,302 @@
package server
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// Pictures for the assistant: the model asks for one in its answer - a
// ```image block, a new picture or an edit of a photo - and the panel makes
// it through the Vertex API (https://s2.x-grk.com/api/vertex/docs). The
// work is a job there: it is put, then asked about until it is done. The
// API key is the admins', set in Настройки and never sent to the browser.
const (
cfgImageKey = "ai_image_key"
cfgImageURL = "ai_image_url" // not in the settings: the service's address, for tests
// DefaultImageURL is where the Vertex API is.
DefaultImageURL = "https://s2.x-grk.com"
imageMaxBytes = 20 << 20 // what the service takes of one photo
)
// imageConf is the service's address and the key (none: pictures are off).
func (s *Server) imageConf() (base, key string) {
base, _ = s.db.GetConfig(cfgImageURL)
if base == "" {
base = DefaultImageURL
}
key, _ = s.db.GetConfig(cfgImageKey)
return strings.TrimRight(base, "/"), strings.TrimSpace(key)
}
// imagePrompt tells the model it can make pictures, and how - when there
// is a key.
func (s *Server) imagePrompt() string {
if _, key := s.imageConf(); key == "" {
return ""
}
return "\n\nКартинки. Ты умеешь рисовать и править фото: сам, по смыслу разговора, без просьбы «вызови инструмент» и без лишних вопросов, " +
"когда пользователь просит нарисовать, сгенерировать, изобразить, сделать картинку, арт, логотип, обои, иллюстрацию " +
"или изменить, дорисовать, убрать, заменить что-то на фото.\n" +
"```image\nописание картинки\n``` - создать новую. Опиши сцену, стиль, свет, детали; язык описания любой.\n" +
"```image edit\nчто изменить на фото\n``` - править фото пользователя: то, что приложено к его сообщению " +
"(приложено несколько - после edit назови файл: ```image edit имя.jpg```); фото нет - последнюю созданную картинку.\n" +
"В первой строке блока можно задать формат: 1:1 (по умолчанию), 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, auto - и качество: " +
"fast (по умолчанию, 1 кредит), artist (5, лучше), 0.2plus (10, лучшее); повышай качество, только если пользователь просит. " +
"Например: ```image 16:9 artist```.\n" +
"Сервис по умолчанию сам улучшает описание (дописывает детали и стиль) - это хорошо для короткого запроса. " +
"Если пользователь дал готовое, точное описание (промпт целиком, логотип «ровно как сказано», текст на картинке, строгая композиция) " +
"или просит ничего не добавлять - добавь noenhance: ```image noenhance 3:2```. Для правки фото это не применяется.\n" +
"Картинку покажет сама панель: ссылку в ответ не вставляй, скажи коротко, что получилось, и предложи поправить. " +
"Делается она до пары минут. Одно действие за ответ."
}
// imageError says in Russian what an error code of the service means.
func imageError(code, msg string) string {
switch code {
case "unauthorized":
return "сервис не принял API-ключ - проверьте его в Настройки › ИИ-ассистент › Генерация изображений"
case "banned":
return "аккаунт сервиса картинок заблокирован"
case "insufficient_credits":
return "на аккаунте сервиса картинок не хватает кредитов"
case "daily_limit":
return "исчерпан дневной лимит картинок"
case "job_busy":
return "уже делается другая картинка - дождитесь её"
case "invalid_prompt":
return "сервис не принял описание (пустое или длиннее 4000 символов)"
case "invalid_image":
return "сервис не принял фото (нужен png, jpg или webp)"
case "image_too_large":
return "фото больше 20 МБ"
case "tokens_exhausted":
return "сервис картинок перегружен - попробуйте позже"
case "generation_failed", "edit_failed":
return "сервис не справился с картинкой (кредиты вернутся)"
case "source_missing":
return "исходное фото потерялось на сервисе - загрузите его заново"
}
if msg == "" {
msg = "сервис картинок ответил ошибкой"
}
return msg
}
var imageClient = &http.Client{Timeout: 90 * time.Second}
// imageCall sends the request to the service with the key and reads its
// JSON; an error is words for the user.
func imageCall(ctx context.Context, req *http.Request, key string, v any) error {
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := imageClient.Do(req)
if err != nil {
return errors.New("сервис картинок не отвечает: " + err.Error())
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var e struct {
OK *bool `json:"ok"`
ErrorCode string `json:"error_code"`
Error string `json:"error"`
}
if json.Unmarshal(b, &e) != nil {
return errors.New("сервис картинок ответил " + resp.Status)
}
if (e.OK != nil && !*e.OK) || resp.StatusCode/100 != 2 {
return errors.New(imageError(e.ErrorCode, e.Error))
}
return json.Unmarshal(b, v)
}
var (
imageRatios = map[string]bool{"1:1": true, "16:9": true, "9:16": true, "3:2": true, "2:3": true, "4:3": true, "3:4": true, "auto": true}
imageModels = map[string]bool{"fast": true, "artist": true, "0.2plus": true}
imageJobRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
imageExt = map[string]string{"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
)
// POST /api/panel/ai/image - {mode: generate|edit, prompt, model, ratio,
// image: {mime, data (base64)} | image_url}: puts the job, answers its id.
func (s *Server) handleAIImage(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
if !s.aiOn(s.currentAdmin(r).ID) {
fail("ассистент выключен в профиле")
return
}
base, key := s.imageConf()
if key == "" {
fail("не задан API-ключ сервиса картинок (Настройки › ИИ-ассистент › Генерация изображений)")
return
}
var in struct {
Mode string `json:"mode"`
Prompt string `json:"prompt"`
Model string `json:"model"`
Ratio string `json:"ratio"`
Enhance *bool `json:"enhance"` // the service improves the description; on unless false
ImageURL string `json:"image_url"`
Image struct {
MIME string `json:"mime"`
Data string `json:"data"`
} `json:"image"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in); err != nil {
fail("плохой запрос")
return
}
in.Prompt = strings.TrimSpace(in.Prompt)
if in.Prompt == "" {
fail("пустое описание картинки")
return
}
if r := []rune(in.Prompt); len(r) > 4000 {
in.Prompt = string(r[:4000])
}
var job struct {
JobID string `json:"job_id"`
Cost int `json:"cost"`
}
ctx := r.Context()
if in.Mode != "edit" {
if !imageModels[in.Model] {
in.Model = "fast"
}
if !imageRatios[in.Ratio] {
in.Ratio = "1:1"
}
enhance := in.Enhance == nil || *in.Enhance
b, _ := json.Marshal(map[string]any{"prompt": in.Prompt, "model": in.Model, "ratio": in.Ratio, "enhance": enhance})
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/generate", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if err := imageCall(ctx, req, key, &job); err != nil {
fail(err.Error())
return
}
} else {
mime, data, err := s.imageSource(ctx, base, in.Image.MIME, in.Image.Data, in.ImageURL)
if err != nil {
fail(err.Error())
return
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", `form-data; name="image"; filename="photo.`+imageExt[mime]+`"`)
h.Set("Content-Type", mime)
part, _ := mw.CreatePart(h)
_, _ = part.Write(data)
_ = mw.WriteField("prompt", in.Prompt)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/edit", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if err := imageCall(ctx, req, key, &job); err != nil {
fail(err.Error())
return
}
}
if job.JobID == "" {
fail("сервис картинок не дал номер задачи")
return
}
writeJSON(w, map[string]any{"ok": true, "job_id": job.JobID, "cost": job.Cost})
}
// imageSource is the photo to edit: sent by the browser, or a picture made
// before, taken from the service's own address (and from no other).
func (s *Server) imageSource(ctx context.Context, base, mime, b64, picURL string) (string, []byte, error) {
if picURL != "" {
u, err := url.Parse(picURL)
bu, _ := url.Parse(base)
if err != nil || bu == nil || u.Host != bu.Host || (u.Scheme != "https" && u.Scheme != "http") {
return "", nil, errors.New("прежнюю картинку можно править только с сервиса картинок")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
resp, err := imageClient.Do(req)
if err != nil || resp.StatusCode/100 != 2 {
if resp != nil {
resp.Body.Close()
}
return "", nil, errors.New("прежняя картинка больше не открывается")
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, imageMaxBytes+1))
mime = strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
if imageExt[mime] == "" {
mime = "image/png"
}
return mime, data, nil
}
mime = strings.ToLower(mime)
if imageExt[mime] == "" {
return "", nil, errors.New("править можно png, jpg и webp")
}
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(data) == 0 {
return "", nil, errors.New("нет фото для правки: прикрепите его к сообщению")
}
if len(data) > imageMaxBytes {
return "", nil, errors.New("фото больше 20 МБ")
}
return mime, data, nil
}
// GET /api/panel/ai/image/{job}?wait=1..45 - the job's state, waiting up to
// wait seconds for it to end.
func (s *Server) handleAIImageJob(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
if !s.aiOn(s.currentAdmin(r).ID) {
fail("ассистент выключен в профиле")
return
}
base, key := s.imageConf()
id := r.PathValue("job")
if key == "" || !imageJobRe.MatchString(id) {
fail("нет такой задачи")
return
}
wait, _ := strconv.Atoi(r.URL.Query().Get("wait"))
if wait < 0 {
wait = 0
}
if wait > 45 {
wait = 45
}
req, _ := http.NewRequest(http.MethodGet, base+"/api/vertex/v1/jobs/"+id+"?wait="+strconv.Itoa(wait), nil)
var st struct {
Status string `json:"status"`
PhotoURL string `json:"photo_url"`
Cost int `json:"cost"`
Balance int `json:"balance"`
ErrorCode string `json:"error_code"`
Error string `json:"error"`
}
if err := imageCall(r.Context(), req, key, &st); err != nil {
fail(err.Error())
return
}
out := map[string]any{"ok": true, "status": st.Status}
switch st.Status {
case "done":
out["photo_url"], out["cost"], out["balance"] = st.PhotoURL, st.Cost, st.Balance
case "failed":
out["error"] = imageError(st.ErrorCode, st.Error)
}
writeJSON(w, out)
}
+305
View File
@@ -0,0 +1,305 @@
package server
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// fakeVertex is the image service: it records what it was asked and answers
// like the real one (a job to poll, a picture when done).
type fakeVertex struct {
srv *httptest.Server
mu sync.Mutex
auth []string
gen map[string]any
edit struct {
prompt string
image []byte
name string
}
polls int
failAs string // an error_code the job ends with
}
func newFakeVertex(t *testing.T) *fakeVertex {
f := &fakeVertex{}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.auth = append(f.auth, r.Header.Get("Authorization"))
// The pictures are open by their address, as at the real service.
if r.URL.Path == "/uploads/userphoto/ai/t.png" {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("\x89PNG-original"))
return
}
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("Authorization") != "Bearer vx_good" {
w.WriteHeader(401)
fmt.Fprint(w, `{"ok":false,"error_code":"unauthorized","error":"Неверный ключ"}`)
return
}
switch {
case r.URL.Path == "/api/vertex/v1/generate":
_ = json.NewDecoder(r.Body).Decode(&f.gen)
w.WriteHeader(202)
fmt.Fprint(w, `{"ok":true,"job_id":"job_gen1","status":"queued","cost":5}`)
case r.URL.Path == "/api/vertex/v1/edit":
if err := r.ParseMultipartForm(32 << 20); err != nil {
w.WriteHeader(400)
fmt.Fprint(w, `{"ok":false,"error_code":"invalid_image","error":"нет фото"}`)
return
}
f.edit.prompt = r.FormValue("prompt")
file, hdr, err := r.FormFile("image")
if err == nil {
f.edit.image, _ = io.ReadAll(file)
f.edit.name = hdr.Filename
}
w.WriteHeader(202)
fmt.Fprint(w, `{"ok":true,"job_id":"job_edit1","status":"queued","cost":2}`)
case strings.HasPrefix(r.URL.Path, "/api/vertex/v1/jobs/"):
f.polls++
if f.failAs != "" {
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"failed","error_code":%q,"error":"сбой"}`, f.failAs)
return
}
if f.polls < 2 {
fmt.Fprint(w, `{"ok":true,"job_id":"x","status":"running"}`)
return
}
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"done","photo_url":"%s/uploads/userphoto/ai/t.png","cost":5,"balance":42}`, f.srv.URL)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.srv.Close)
return f
}
// setImage points the panel at the fake service with a key.
func setImage(t *testing.T, s *Server, f *fakeVertex, key string) {
t.Helper()
_ = s.db.SetConfig(cfgImageURL, f.srv.URL)
_ = s.db.SetConfig(cfgImageKey, key)
}
func apiCall(t *testing.T, s *Server, c *http.Cookie, method, path, body string) string {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
// A picture from nothing: the prompt, model and ratio go to the service with
// the key; the job's progress and its picture come back, the key never.
func TestImageGenerate(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"cat in space","model":"artist","ratio":"3:4"}`)
if !strings.Contains(got, `"ok":true`) || !strings.Contains(got, `"job_id":"job_gen1"`) || strings.Contains(got, "vx_good") {
t.Fatalf("create: %s", got)
}
if f.gen["prompt"] != "cat in space" || f.gen["model"] != "artist" || f.gen["ratio"] != "3:4" || f.gen["enhance"] != true {
t.Fatalf("what the service got: %v", f.gen)
}
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", ""); !strings.Contains(got, `"status":"running"`) {
t.Fatalf("first poll: %s", got)
}
got = apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", "")
if !strings.Contains(got, `"status":"done"`) || !strings.Contains(got, "/uploads/userphoto/ai/t.png") || !strings.Contains(got, `"balance":42`) || strings.Contains(got, "vx_good") {
t.Fatalf("done: %s", got)
}
}
// Unknown models and ratios are not sent on: the defaults go instead.
func TestImageGenerateDefaults(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","model":"gpt","ratio":"7:1"}`)
if f.gen["model"] != "fast" || f.gen["ratio"] != "1:1" {
t.Fatalf("defaults: %v", f.gen)
}
}
// Editing sends the user's photo as a file with the instruction.
func TestImageEditFromAttachedPhoto(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
photo := []byte("\xff\xd8\xffJPEGDATA")
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "сделай закат",
"image": map[string]string{"mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(photo)}})
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b))
if !strings.Contains(got, `"job_id":"job_edit1"`) {
t.Fatalf("edit: %s", got)
}
if f.edit.prompt != "сделай закат" || !bytes.Equal(f.edit.image, photo) || !strings.HasSuffix(f.edit.name, ".jpg") {
t.Fatalf("what the service got: %q %q %q", f.edit.prompt, f.edit.image, f.edit.name)
}
// A format the service does not take is told so, not sent.
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image": map[string]string{"mime": "image/gif", "data": "R0lG"}})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "png") {
t.Fatalf("gif: %s", got)
}
}
// A picture made before can be edited again: fetched from the service's own
// address only - never from another.
func TestImageEditFromEarlierPicture(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "темнее", "image_url": f.srv.URL + "/uploads/userphoto/ai/t.png"})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":true`) {
t.Fatalf("by url: %s", got)
}
if !bytes.Equal(f.edit.image, []byte("\x89PNG-original")) || !strings.HasSuffix(f.edit.name, ".png") {
t.Fatalf("the picture sent: %q %q", f.edit.image, f.edit.name)
}
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image_url": "http://127.0.0.1:9/secret.png"})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) {
t.Fatalf("a foreign address was fetched: %s", got)
}
}
// What goes wrong is told in words: no key, a wrong key, the service's own
// codes, a job that failed.
func TestImageErrors(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "ключ") {
t.Fatalf("no key: %s", got)
}
setImage(t, s, f, "vx_bad")
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "ключ") {
t.Fatalf("wrong key: %s", got)
}
setImage(t, s, f, "vx_good")
f.failAs = "insufficient_credits"
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1", ""); !strings.Contains(got, `"status":"failed"`) || !strings.Contains(got, "кредит") {
t.Fatalf("failed job: %s", got)
}
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/..%2Fme", ""); !strings.Contains(got, `"ok":false`) {
t.Fatalf("a job id out of shape: %s", got)
}
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":""}`); !strings.Contains(got, `"ok":false`) {
t.Fatalf("empty prompt: %s", got)
}
}
// The assistant off in the profile means no pictures either.
func TestImageNeedsAssistantOn(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "выключен") {
t.Fatalf("off: %s", got)
}
}
// The key is saved from Настройки, shown back to the admin, and the chat
// learns only that pictures are on.
func TestImageKeySetting(t *testing.T) {
s, c := newTestServer(t)
postAIModels(t, s, c, [][7]string{{"", "M", "gradio", "", "", "", ""}})
f := url.Values{"csrf": {"tok"}, "m_type": {"gradio"}, "ai_image_key": {"vx_secret"}}
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
s.Handler().ServeHTTP(httptest.NewRecorder(), req)
if _, k := s.imageConf(); k != "vx_secret" {
t.Fatalf("key not saved: %q", k)
}
if body := get(t, s, c, "/settings?frag=1").Body.String(); !strings.Contains(body, `value="vx_secret"`) {
t.Fatal("the settings page does not show the key")
}
state := get(t, s, c, "/api/panel/ai").Body.String()
if strings.Contains(state, "vx_secret") || !strings.Contains(state, `"image":true`) {
t.Fatalf("state: %s", state)
}
}
// With a key the model is told it can draw; without, it is not.
func TestImagePromptOnlyWithKey(t *testing.T) {
s, _ := newTestServer(t)
if strings.Contains(s.imagePrompt(), "```image") {
t.Fatal("told about pictures with no key")
}
_ = s.db.SetConfig(cfgImageKey, "vx_good")
p := s.imagePrompt()
for _, want := range []string{"```image\n", "```image edit", "fast", "artist", "0.2plus", "16:9"} {
if !strings.Contains(p, want) {
t.Fatalf("the prompt lacks %q:\n%s", want, p)
}
}
}
// The chat gives the model the image instructions when pictures are on.
func TestAIChatMentionsImageTool(t *testing.T) {
s, c := newTestServer(t)
var system string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
system, _ = b["messages"].([]any)[0].(map[string]any)["content"].(string)
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
}))
defer srv.Close()
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
ask := func() {
apiCall(t, s, c, "POST", "/api/panel/ai/chat", `{"message":"нарисуй кота","context":"x"}`)
}
ask()
if strings.Contains(system, "```image") {
t.Fatal("image tool offered with no key")
}
_ = s.db.SetConfig(cfgImageKey, "vx_good")
ask()
if !strings.Contains(system, "```image") {
t.Fatalf("image tool not offered with a key:\n%s", system)
}
}
// The description is improved by the service unless the model asks for it
// as it is.
func TestImageEnhanceSwitch(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`)
if f.gen["enhance"] != true {
t.Fatalf("by default: %v", f.gen)
}
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":false}`)
if f.gen["enhance"] != false {
t.Fatalf("switched off: %v", f.gen)
}
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":true}`)
if f.gen["enhance"] != true {
t.Fatalf("switched on: %v", f.gen)
}
{
p := s.imagePrompt()
_ = s.db.SetConfig(cfgImageKey, "vx_good")
if !strings.Contains(s.imagePrompt(), "noenhance") {
t.Fatalf("the model is not told of the switch:\n%s", p)
}
}
}
+14 -1
View File
@@ -4,12 +4,15 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
// is the same on every device: where a tab's icons go, the terminal's font
// size, a compact file list, the time column, animations, and whether a
// terminal or file pane opened «Здесь» starts in the active pane's folder.
// terminal or file pane opened «Здесь» starts in the active pane's folder;
// and the assistant's model: which one, and whether the chat always offers
// the choice.
type viewPrefs struct {
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
@@ -17,6 +20,10 @@ type viewPrefs struct {
FileTime string `json:"fileTime,omitempty"` // show (default), hide
Motion string `json:"motion,omitempty"` // on (default), off
OpenHere string `json:"openHere,omitempty"` // on (default), off
// AIModelPick: the model menu in the chat - when there are several
// (default) or always. AIModel: the id of the model chosen.
AIModelPick string `json:"aiModelPick,omitempty"`
AIModel string `json:"aiModel,omitempty"`
}
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
@@ -38,6 +45,12 @@ func (p viewPrefs) clean() viewPrefs {
if p.OpenHere != "off" {
p.OpenHere = ""
}
if p.AIModelPick != "always" {
p.AIModelPick = ""
}
if len(p.AIModel) > 32 || strings.Trim(p.AIModel, "0123456789abcdefghijklmnopqrstuvwxyz") != "" {
p.AIModel = ""
}
return p
}
+1 -1
View File
@@ -262,7 +262,7 @@ func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
if !s.aiOn(a.ID) {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
+10
View File
@@ -65,6 +65,9 @@ type Server struct {
wsAgents wsAgents // agents on their WebSocket now
pinMisses pinMisses // wrong PINs per session
assets map[string]string // static file -> hash of its content
upd updState // the server's own updating
updRun func(script []byte) error // runs the install script; the real one unless a test sets it
}
// New builds the server, parsing embedded templates.
@@ -266,7 +269,14 @@ func (s *Server) Handler() http.Handler {
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 /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /settings/autoupdate", s.adminsOnly(s.handleSetAutoUpdate))
mux.Handle("POST /api/panel/update/check", s.adminsOnly(s.handleUpdateCheck))
mux.Handle("GET /api/panel/update/offer", s.adminsOnly(s.handleUpdateOffer))
mux.Handle("POST /api/panel/update/decline", s.adminsOnly(s.handleUpdateDecline))
mux.Handle("POST /api/panel/update/run", s.adminsOnly(s.handleUpdateRun))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
+1
View File
@@ -62,6 +62,7 @@
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
' <label><span>Выбор модели в чате</span><select data-pref="aiModelPick"><option value="">когда моделей несколько</option><option value="always">всегда</option></select></label>' +
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
' </div></details>' +
' <div class="acct-keysec">' +
+249 -23
View File
@@ -7,7 +7,7 @@
(function () {
'use strict';
const PLANE = '<svg class="ai-plane" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.4 20.85 12.9a1 1 0 0 0 0-1.8L3.4 3.6a1 1 0 0 0-1.39 1.17L4 11l9 1-9 1-1.99 6.23a1 1 0 0 0 1.39 1.17z"/></svg>';
const STOP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
const STOP = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
@@ -34,6 +34,10 @@
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
// The model, when there is a choice: its name; a click opens the list.
'<div class="ai-mpick" hidden><button type="button" class="ai-mbtn" aria-haspopup="listbox" aria-expanded="false" title="Модель">' +
'<span class="ai-mname"></span><svg class="ai-mchev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>' +
'<div class="ai-mmenu" role="listbox" aria-label="Модель"></div></div>' +
'</div>' +
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
@@ -73,6 +77,7 @@
const isOpen = () => box.classList.contains('on');
function show() {
loadTalk();
loadState();
placeBox();
box.classList.add('on');
fab.classList.add('away');
@@ -102,7 +107,18 @@
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
const desk = !matchMedia('(pointer: coarse)').matches;
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
// The models to choose from come with the state, again on every opening
// (an admin may have changed them).
let models = [];
// Pictures are on when the admin set the image service's key: the model then
// asks for them in its answers (```image blocks) - no switch of the user's.
let imageOn = false;
// The photos of the user's last message that had any, and the last picture
// made here: what an edit works on.
let sentImages = [], lastPic = '';
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
loadState();
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
@@ -204,7 +220,6 @@
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
row.querySelector('.ai-chatdel').onclick = async ev => {
ev.stopPropagation();
if (!await UI.confirm('Удалить чат «' + c.title + '»?', {ok: 'Удалить', danger: true})) return;
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
if (c.id === chatId) openChat(0);
loadChats();
@@ -228,6 +243,7 @@
setChat(id);
drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
sentImages = []; lastPic = '';
list.scrollTop = 0;
showJump(false);
hideFileError();
@@ -238,7 +254,7 @@
if (chatId !== id) return;
h.forEach(m => {
if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; if (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
});
if (!h.length) q('.ai-hello').hidden = false;
list.scrollTop = list.scrollHeight;
@@ -358,6 +374,7 @@
const p = r.data;
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
}
if (a.kind === 'image') return makeImage(a, signal);
// A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
@@ -412,13 +429,16 @@
// ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
// What the button of a card that has been done says (else «Готово»).
const DONE = {image: 'Создано'};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
// 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
@@ -458,16 +478,67 @@
// An action: a card with what and where on its title line, a small
// button on the right of it, the command or text below.
if (act) {
const where = head.slice(kind.length).trim();
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
// A picture's title says what it is (a new one or an edit); its first line
// (format, quality, file) is not shown - the words under it are.
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
// The text of a picture or a search folds: a line, or open - up to
// a few lines, then it scrolls. What the user left open stays so.
const fold = (kind === 'image' || kind === 'search') && code;
const open = fold && el._open && el._open.has(idx);
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
ICONS[kind] + '<b>' + title + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
'<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + LABEL[kind] + '</button></div>' +
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
});
body.innerHTML = html;
el.dataset.text = text;
// A text that fits one line has nothing to unfold: no arrow for it.
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
const p = c.querySelector('pre');
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
}));
}
// animateHeight: change() flips the card between folded and open; the
// text's box grows or shrinks to its new height instead of jumping. The
// text keeps its wrapped lines while it shrinks.
function animateHeight(pre, change) {
const h0 = pre.getBoundingClientRect().height;
change();
const still = document.body.classList.contains('no-motion') || matchMedia('(prefers-reduced-motion: reduce)').matches;
const h1 = pre.getBoundingClientRect().height;
if (still || Math.abs(h1 - h0) < 1) return;
const st = pre.style;
st.boxSizing = 'border-box'; st.overflow = 'hidden'; st.whiteSpace = 'pre-wrap'; st.textOverflow = 'clip';
st.height = h0 + 'px';
pre.getBoundingClientRect(); // the start is drawn before the end is set
st.transition = 'height .24s cubic-bezier(.3,.7,.3,1)';
st.height = h1 + 'px';
let t = 0;
const done = () => {
clearTimeout(t); pre.removeEventListener('transitionend', done);
st.height = st.transition = st.overflow = st.whiteSpace = st.textOverflow = st.boxSizing = '';
};
pre.addEventListener('transitionend', done);
t = setTimeout(done, 450);
}
const FOLD_CHEV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
list.addEventListener('click', ev => {
const t = ev.target.closest('.foldable .ai-fold, .foldable pre');
if (!t) return;
const card = t.closest('.foldable');
if (card.classList.contains('short') && !card.classList.contains('open')) return;
const msg = card.closest('.ai-msg'), pre = card.querySelector('pre');
let on = false;
animateHeight(pre, () => { on = card.classList.toggle('open'); });
const i = Number(card.dataset.n);
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
const b = card.querySelector('.ai-fold');
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
});
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
@@ -505,7 +576,14 @@
fileErrTimer = setTimeout(hideFileError, 5000);
}
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
// The file itself stays with its attachment (not sent anywhere): it is what
// a click on its name opens or saves.
async function makeAttachments(file, name = file.name) {
const made = await makeAttachmentsOf(file, name);
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
return made;
}
async function makeAttachmentsOf(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 === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
@@ -541,7 +619,8 @@
}
}
const FILE_KIND = file => {
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
// Of an earlier talk only the name is kept: the kind comes from it.
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').toLowerCase();
if (mime.startsWith('image/')) return ['image', 'Изображение'];
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
if (mime.startsWith('video/')) return ['video', 'Видео'];
@@ -557,6 +636,30 @@
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
};
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
// is shown as plain text, so it cannot run anything; the rest is saved.
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
function openAttached(file) {
let blob = file.blob || null, show = false;
// The type of the file itself - not of its text (a Word file's attachment
// is its text, plain, but the file is not).
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
if (!blob && file.data) {
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
blob = new Blob([bytes], {type});
}
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
if (!blob) return;
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url;
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
function renderFileCards(host, files) {
if (!files.length) return;
const group = document.createElement('div'); group.className = 'ai-history-files';
@@ -566,7 +669,13 @@
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
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;
// The name opens the file (or saves it) - when it is at hand: this
// page's own attachments; of an earlier talk only a text file's text.
const has = !!(file.blob || file.data || file.text);
const name = document.createElement(has ? 'button' : 'span');
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
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) : '');
@@ -612,8 +721,56 @@
// the next question (or by note, when done by hand).
function showResult(text) {
const el = add('result', '');
// A picture made: shown here, from its marker (kept with the talk, so
// it comes back with it) - and what the next edit works on. Its words
// are the model's alone: the user sees the picture.
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
if (!m) {
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text;
return;
}
lastPic = m[1];
const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img');
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
img.alt = 'Картинка'; img.src = m[1];
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img);
el.classList.add('pic');
el.appendChild(link);
}
// makeImage: a picture asked for in an answer - put at the image service
// through the server, asked about until it is done. What comes back is
// what the model is told: that it is made and shown (or why not).
async function makeImage(a, signal) {
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
if (a.edit) {
const want = (a.file || '').toLowerCase();
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
if (pick) body.image = {mime: pick.mime, data: pick.data};
else if (lastPic) body.image_url = lastPic;
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
}
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
if (!r.ok) return 'Ошибка картинки: ' + r.error;
let lost = 0;
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
if (!s.ok) {
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
return 'Ошибка картинки: ' + s.error;
}
if (s.status === 'done') {
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
}
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
}
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
}
list.addEventListener('click', async ev => {
const b = ev.target.closest('.ai-act');
@@ -623,7 +780,7 @@
b.disabled = true;
b.textContent = '…';
const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
});
@@ -647,14 +804,13 @@
// While the model starts: a typing mark, gone with the first words.
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
ac = new AbortController();
send.innerHTML = STOP; send.title = 'Остановить';
sendState();
let answer = '';
try {
await loadMachines();
const r = await fetch('/api/panel/ai/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []})
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || [], model: modelId()})
});
// A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
@@ -690,6 +846,61 @@
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
// ---------- the model ----------
// Shown when there are several models, or always if the user asked for it
// (Аккаунт › Вид); the choice is the user's, kept on the server.
const mpick = q('.ai-mpick'), mbtn = q('.ai-mbtn'), mmenu = q('.ai-mmenu');
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
function modelId() {
const want = window.Prefs ? Prefs.get().aiModel : '';
return (models.find(m => m.id === want) || models[0] || {}).id || '';
}
function drawModels() {
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
mpick.hidden = !(models.length > 1 || (always && models.length));
const cur = modelId(), m = models.find(x => x.id === cur);
// The button: the short name if there is one; the list: the full names.
q('.ai-mname').textContent = m ? (m.short || m.name) : '';
mpick.classList.toggle('short', !!(m && m.short));
mbtn.title = m ? 'Модель: ' + m.name : 'Модель';
mmenu.innerHTML = '';
models.forEach(x => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'ai-mitem'; b.setAttribute('role', 'option');
b.setAttribute('aria-selected', String(x.id === cur));
b.innerHTML = '<span class="ai-mitem-name"></span><span class="ai-mcheck">' + CHECK + '</span>';
b.querySelector('.ai-mitem-name').textContent = x.name;
b.onclick = () => {
closeModels();
if (window.Prefs) Prefs.set('aiModel', x.id);
drawModels();
input.focus();
};
mmenu.appendChild(b);
});
}
function openModels() {
mpick.classList.add('open');
mbtn.setAttribute('aria-expanded', 'true');
const sel = mmenu.querySelector('[aria-selected="true"]') || mmenu.firstChild;
if (sel) sel.focus();
}
function closeModels() {
mpick.classList.remove('open');
mbtn.setAttribute('aria-expanded', 'false');
}
mbtn.onclick = () => mpick.classList.contains('open') ? closeModels() : openModels();
document.addEventListener('pointerdown', ev => { if (!mpick.contains(ev.target)) closeModels(); }, true);
mpick.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && mpick.classList.contains('open')) { ev.stopPropagation(); closeModels(); mbtn.focus(); return; }
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
ev.preventDefault();
if (!mpick.classList.contains('open')) { openModels(); return; }
const items = [...mmenu.children], i = items.indexOf(document.activeElement);
const n = items[(i + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
if (n) n.focus();
});
document.addEventListener('lpra:prefs', drawModels);
// The modes are kept in this browser.
box.querySelectorAll('.ai-pill').forEach(b => {
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
@@ -703,26 +914,28 @@
// first action of each answer done (asking first for commands and writes)
// and its result sent back - until an answer asks for nothing.
async function talkTurn(text, files) {
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
if (imgs.length) sentImages = imgs;
let turn = await ask(text, null, files);
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on -
// and pictures, whenever they are on: the model asks for them itself.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) break;
const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await confirmAction(a)) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
continue;
}
if (b) { b.disabled = true; b.textContent = '…'; }
if (b) { b.disabled = true; b.textContent = a.kind === 'image' ? 'Рисую…' : '…'; }
const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
if (ac.signal.aborted) { showResult(res); break; }
turn = await ask(null, res);
}
ac = null;
send.innerHTML = PLANE; send.title = 'Отправить';
sendState();
}
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
@@ -789,10 +1002,23 @@
dropFiles: addPaneFiles
};
// Send shows only when there is something to send, or an answer to stop.
// While it fades away it keeps the look it had (a red stop stays a red
// stop) and becomes the plane only once hidden.
let sendLookTimer = 0;
function sendLook(stop) {
clearTimeout(sendLookTimer);
if (send.classList.contains('stop') === stop && send.dataset.look) return;
send.dataset.look = '1';
send.innerHTML = stop ? STOP : PLANE;
send.title = stop ? 'Остановить' : 'Отправить';
send.setAttribute('aria-label', send.title);
send.classList.toggle('stop', stop);
}
function sendState() {
const on = !!ac || !!input.value.trim() || attachments.length > 0;
if (on) sendLook(!!ac);
else { clearTimeout(sendLookTimer); sendLookTimer = setTimeout(() => sendLook(false), 260); }
send.classList.toggle('show', on);
send.classList.toggle('stop', !!ac);
send.tabIndex = on ? 0 : -1;
}
input.addEventListener('input', sendState);
@@ -812,8 +1038,8 @@
// its width and wrap the last word a character too early.
function grow() {
input.style.overflowY = 'hidden';
input.style.height = '40px';
const h = input.scrollHeight + 2;
input.style.height = '32px';
const h = input.scrollHeight;
input.style.height = Math.min(140, h) + 'px';
input.style.overflowY = h > 140 ? 'auto' : 'hidden';
}
+83 -14
View File
@@ -844,7 +844,7 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov
display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.ai-fab[hidden] { display:none; }
body.touch.term-active .ai-fab { bottom:104px; }
.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:408px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px;
box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right;
opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96);
@@ -885,6 +885,10 @@ body.touch.term-active .ai-fab { bottom:104px; }
.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; }
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
/* The model starting: three dots rising in turn. */
@@ -896,30 +900,27 @@ 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 { 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 { position:relative; min-width:0; flex:1; padding:4px; 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); }
/* The clip and send keep to the last line of the text, as in Telegram:
at the bottom, centred on that line (2px up from the field's bottom). */
at the bottom, centred on that line (a line's field is their height). */
.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: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 { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:6px; height:32px; 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 { flex:none; width:36px; height:36px; padding:0; margin-bottom:2px; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach { flex:none; width:32px; height:32px; 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; }
/* Send, as in Telegram: no plate, a blue plane that comes in when there is
something to send (and stays as "stop" while the answer runs), and goes
away when the field is empty. A round shade under the pointer, like the
clip's. As far from the right edge as from the top and bottom. */
.ai-send { flex:none; box-sizing:border-box; width:0; height:36px; min-width:0; padding:0; margin:0 0 2px -5px; overflow:hidden;
.ai-send { flex:none; box-sizing:border-box; width:0; height:32px; min-width:0; padding:0; margin:0 0 0 -5px; overflow:hidden;
display:inline-flex; align-items:center; justify-content:center; align-self:flex-end; line-height:1;
color:var(--accent); background:transparent; border:0; border-radius:50%;
opacity:0; transform:scale(.4); pointer-events:none;
transition:width .2s ease, margin .2s ease, opacity .16s ease, transform .24s cubic-bezier(.3,1.45,.55,1), background-color .15s, color .15s; }
.ai-send.show { width:36px; margin:0 3px 2px 0; opacity:1; transform:none; pointer-events:auto; }
.ai-send.show { width:32px; margin:0; opacity:1; transform:none; pointer-events:auto; }
.ai-send:hover { color:#7ab6ff; background:#1b222c; }
.ai-send:active { transform:scale(.9); }
/* While the answer runs: stop, red. */
.ai-send.stop { color:var(--danger); }
.ai-send.stop:hover { color:#ff7b7b; background:#2a1a1d; }
.ai-send:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
.ai-send .ai-plane { display:block; flex:none; transform:translateX(1px); }
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
@@ -948,6 +949,7 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
.uidlg .ui-text { overflow:hidden; }
.uidlg .ui-long { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom; }
.uidlg .ui-input[hidden] { display:none; }
.uidlg .ui-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); }
.sshdlg .chk { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
.sshdlg .chk input { width:auto; }
.sshdlg .saved-row[hidden] { display:none; }
@@ -956,11 +958,44 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
.ai-agent input { width:auto; margin:0; }
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
.ai-msg.result summary { cursor:pointer; }
/* A picture the model made: shown whole, the result folded small under it. */
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
.ai-cap { font-size:12px; margin-bottom:4px; }
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
.ai-pills { display:flex; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); }
.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px; border-radius:999px; font-size:12.5px; font-weight:500;
.ai-pills { display:flex; align-items:center; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); }
.ai-pills .ai-pill { flex:none; }
/* The model: its name and a chevron at the right of the pills, quiet until
pointed at; the list opens upwards, as a small card. */
.ai-mpick { position:relative; margin-left:auto; min-width:0; }
.ai-mpick[hidden] { display:none; }
/* A short name (12 characters at most) is shown whole. */
.ai-mpick.short { flex-shrink:0; }
.ai-mbtn { display:inline-flex; align-items:center; gap:3px; max-width:100%; height:28px; padding:0 5px 0 7px; border:0; border-radius:8px;
background:transparent; color:var(--muted); font-size:12.5px; font-weight:500; transition:background-color .15s, color .15s; }
.ai-mbtn:hover, .ai-mpick.open .ai-mbtn { color:var(--text); background:#1b222c; }
.ai-mbtn:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
.ai-mname { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-mchev { flex:none; opacity:.8; transition:transform .2s ease; }
.ai-mpick.open .ai-mchev { transform:rotate(180deg); }
.ai-mmenu { position:absolute; right:0; bottom:calc(100% + 8px); z-index:6; min-width:210px; max-width:280px; max-height:260px; overflow:auto;
display:grid; gap:2px; padding:5px; background:var(--panel); border:1px solid var(--border); border-radius:12px;
box-shadow:0 12px 32px rgba(0,0,0,.5); transform-origin:bottom right;
opacity:0; visibility:hidden; transform:translateY(6px) scale(.98); pointer-events:none;
transition:opacity .16s ease, transform .16s ease, visibility 0s linear .16s; }
.ai-mmenu::before { content:'Модель'; padding:5px 10px 4px; font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
.ai-mpick.open .ai-mmenu { opacity:1; visibility:visible; transform:none; pointer-events:auto; transition:opacity .16s ease, transform .16s ease; }
.ai-mitem { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; padding:8px 10px; border:0; border-radius:8px;
background:transparent; color:var(--text); font-size:13.5px; font-weight:450; text-align:left; }
.ai-mitem:hover, .ai-mitem:focus-visible { outline:none; background:#1b222c; }
.ai-mitem-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-mcheck { flex:none; display:flex; color:var(--accent); visibility:hidden; }
.ai-mitem[aria-selected="true"] .ai-mcheck { visibility:visible; }
.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 10px; border-radius:999px; font-size:12.5px; font-weight:500;
background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; }
.ai-pill:hover { color:var(--text); border-color:#3a4554; }
.ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); }
@@ -981,7 +1016,7 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
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-form-in textarea { min-height:32px; }
.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; }
@@ -1067,7 +1102,7 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
.ai-kind { display:inline-flex; align-items:center; gap:6px; min-width:0; color:var(--muted); }
.ai-kind svg { flex:none; color:var(--accent); }
.ai-kind svg { align-self:center; flex:none; color:var(--accent); }
.ai-kind b { font-weight:500; color:var(--text); flex:none; }
.ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; }
.ai-act { margin-left:auto; flex:none; height:24px; padding:0 9px; min-height:0; line-height:24px; border-radius:6px; border:0; font-size:12px; font-weight:500;
@@ -1075,6 +1110,18 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
lines and then it scrolls. */
.ai-code.foldable { --ai-fold-lines:6; }
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
.ai-code.foldable.short pre { cursor:default; }
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
.ai-fold:hover { color:var(--text); background:#1b222c; }
.ai-fold svg { transition:transform .2s ease; }
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
.ai-code.foldable.short .ai-fold { display:none; }
/* Аккаунт › Вид. */
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.acct-view summary { font-size:14px; color:var(--text); }
@@ -1099,6 +1146,28 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
.fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; }
/* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
/* Настройки: the models, a card each. */
.ai-models { display:grid; gap:10px; }
.ai-model, .ai-search { min-width:0; margin:0; border:1px solid var(--border); border-radius:10px; }
/* Folded to a line: its name, and its kind and model quieter. */
.ai-model > summary.ai-model-sum, .ai-search > summary.ai-model-sum { min-width:0; padding:11px 12px; border-radius:10px; }
.ai-model > summary.ai-model-sum:hover, .ai-search > summary.ai-model-sum:hover { background:#151a21; }
.ai-model-title { flex:none; color:var(--text); font-size:14px; font-weight:550; }
.ai-model-sub { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12.5px; }
.ai-model-sub:not(:empty)::before { content:'· '; }
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
.ai-form label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
.ai-form label.check input { margin:0; }
.auto-every { display:flex; align-items:center; gap:8px; }
.auto-every input[type=number] { width:84px; flex:none; }
.auto-every select { width:auto; flex:none; }
.auto-status { font-size:13px; line-height:1.5; }
.auto-status .bad { color:var(--danger); }
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:2px 0 4px; }
.ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; }
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
.ai-model-del { flex:none; height:38px; }
.ai-models .ai-model:only-child .ai-model-del { display:none; }
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; }
/* A question about an action: where, the command in mono, a note. */
+52 -1
View File
@@ -23,6 +23,57 @@
}, e => alert('Не скопировалось: ' + e.message));
});
if (window.UsersTable) window.UsersTable.mount(root);
// Автообновление: look at GitLab now, and update now.
const au = root.querySelector('#autoupdate .auto-check');
if (au) {
const st = root.querySelector('#autoupdate .auto-status'), run = root.querySelector('#autoupdate .auto-run');
const post = u => fetch(u, {method: 'POST', credentials: 'same-origin'}).then(r => r.json()).catch(() => ({ok: false, error: 'нет связи с сервером'}));
const say = (text, bad) => { st.textContent = ''; const s = document.createElement('span'); s.textContent = text; if (bad) s.className = 'bad'; st.appendChild(s); };
au.onclick = async () => {
au.disabled = true; say('Проверяю…');
const r = await post('/api/panel/update/check');
au.disabled = false;
if (!r.ok) { say(r.error, true); return; }
say('Версия сервера: ' + r.current + ' · на GitLab: ' + r.latest + (r.newer ? ' - вышла новая' : ' - это последняя'));
run.hidden = !r.newer;
};
run.onclick = async () => {
if (!await UI.confirm('Обновить сервер сейчас? Панель будет недоступна около минуты.', {ok: 'Обновить'})) return;
run.disabled = true; say('Запускаю обновление…');
const r = await post('/api/panel/update/run');
run.disabled = false;
if (!r.ok) { say(r.error, true); return; }
run.hidden = true;
say('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу.');
};
}
// ИИ-ассистент: a card per model, added from the blank one and taken
// away by its «Удалить» (the last one stays).
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
if (list && blank) {
root.querySelector('.ai-model-add').onclick = () => {
const card = blank.content.firstElementChild.cloneNode(true);
list.appendChild(card);
card.querySelector('[name=m_name]').focus();
};
list.addEventListener('click', ev => {
const del = ev.target.closest('.ai-model-del');
if (del && list.children.length > 1) del.closest('.ai-model').remove();
});
// A folded card's line follows its fields: the name, the kind and model.
list.addEventListener('input', ev => {
const card = ev.target.closest('.ai-model');
if (!card) return;
const v = n => card.querySelector('[name=' + n + ']').value.trim();
card.querySelector('.ai-model-title').textContent = v('m_name') || 'Модель';
card.querySelector('.ai-model-sub').textContent = v('m_type') + (v('m_model') ? ' · ' + v('m_model') : '');
});
// A field to fix in a folded card: the card opens to show it.
list.closest('form').addEventListener('invalid', ev => {
const d = ev.target.closest('details');
if (d) d.open = true;
}, true);
}
}
});
@@ -75,7 +126,7 @@
App.register('forwards', {
mount(root) {
var notes = {
http: 'Трафик идёт через порт панели ' + root.dataset.panelPort + ' (long-poll). Порт ' + root.dataset.tunnelPort + ' не требуется.',
http: 'Трафик идёт через соединение агента с панелью (порт ' + root.dataset.panelPort + '): по WebSocket, без него - long-poll. Порт ' + root.dataset.tunnelPort + ' не требуется.',
tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
};
+2
View File
@@ -5,6 +5,8 @@
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
function apply() {
const b = document.body;
// Parts that follow a setting of their own (the chat's model menu).
document.dispatchEvent(new CustomEvent('lpra:prefs'));
b.dataset.tabicons = p.tabIcons || 'left';
b.classList.toggle('compact', !!p.compact);
b.classList.toggle('no-ftime', p.fileTime === 'hide');
+2 -1
View File
@@ -8,7 +8,7 @@
function make() {
dlg = document.createElement('dialog');
dlg.className = 'uidlg';
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input class="ui-input" autocomplete="off" spellcheck="false">' +
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input type="text" class="ui-input" autocomplete="off" spellcheck="false">' +
'<div class="row end"><button type="button" class="btn ghost close ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
document.body.appendChild(dlg);
}
@@ -50,6 +50,7 @@
input.value = o.value || '';
yes.textContent = o.ok || 'OK';
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
dlg.querySelector('.ui-no').textContent = o.cancel || 'Отмена';
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
return new Promise(done => {
+47
View File
@@ -0,0 +1,47 @@
// A new release of the server: an administrator is offered it when they come
// in - after the PIN, or at once when there is none - once for each tab. The
// window says the versions and has «Обновить» and «Отказаться»; a refusal is
// kept for that release (a still newer one asks again). Only when the server
// is set to look for releases (Настройки › Автообновление).
(function () {
'use strict';
var root = document.documentElement;
var SHOWN = 'lpra.upd.shown';
// The PIN lock hides the page until it is unlocked.
function whenUnlocked(fn) {
if (!root.classList.contains('pin-wait')) { fn(); return; }
var mo = new MutationObserver(function () {
if (!root.classList.contains('pin-wait')) { mo.disconnect(); fn(); }
});
mo.observe(root, {attributes: true, attributeFilter: ['class']});
}
function toast(text, bad) {
if (window.FileViews && FileViews.toast) FileViews.toast(text, bad);
}
function post(url, body) {
return fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify(body || {})}).then(function (r) { return r.json(); })
.catch(function () { return {ok: false, error: 'нет связи с сервером'}; });
}
function offer() {
try { if (sessionStorage.getItem(SHOWN) === '1') return; } catch (e) { /* offered again */ }
fetch('/api/panel/update/offer', {cache: 'no-store', credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : {}; })
.then(function (d) {
if (!d.offer || !window.UI) return;
try { sessionStorage.setItem(SHOWN, '1'); } catch (e) { /* not remembered */ }
UI.confirm('Вышла новая версия LPRA {chip}\nОбновить сервер сейчас? Панель будет недоступна около минуты.',
{ok: 'Обновить', cancel: 'Отказаться', chip: d.current + ' → ' + d.latest}).then(function (yes) {
if (!yes) { post('/api/panel/update/decline', {version: d.latest}); return; }
post('/api/panel/update/run').then(function (r) {
if (r.ok) toast('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу');
else toast(r.error || 'обновить не получилось', true);
});
});
})
.catch(function () { /* no offer */ });
}
whenUnlocked(offer);
})();
+15 -2
View File
@@ -46,12 +46,25 @@
// parseActions finds what an assistant's answer asks the panel to do:
// closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text).
// write [machine:]path (its body the file's text); search, fetch, and image
// (a picture to make or - with edit - to change; the first line may give
// its ratio, quality and the photo's file name).
function parseActions(text) {
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\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 === 'image') {
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
file = rest.trim();
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
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) : '';
+17
View File
@@ -85,3 +85,20 @@ 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'}]);
});
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
});
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
});
+1
View File
@@ -213,6 +213,7 @@
<script src="{{asset "account.js"}}"></script>
<script src="{{asset "machine.js"}}"></script>
<script src="{{asset "ai.js"}}"></script>
<script src="{{asset "updatenote.js"}}"></script>
<script>App.start();</script>
</body>
</html>{{end}}
+13 -15
View File
@@ -10,22 +10,19 @@
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
}
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
/* The ufw switch hangs under the button, centred on it, level with the note. */
.frow .act { position:relative; }
.frow .act label.check {
position:absolute; top:100%; left:50%; transform:translateX(-50%);
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
color:var(--text); font-size:14px; cursor:pointer;
}
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
/* Under the fields: the note, and the ufw switch at the right, under the
button - on a line of its own when the room runs out, never over the note. */
.under { display:flex; align-items:flex-start; justify-content:space-between; gap:8px 16px; flex-wrap:wrap;
min-height:20px; margin:12px 0 0; font-size:13px; line-height:20px; }
.under #note { flex:1 1 240px; min-width:0; }
.under label.check { flex:none; display:inline-flex; align-items:center; gap:8px; height:20px; margin:0 0 0 auto;
color:var(--text); font-size:14px; cursor:pointer; white-space:nowrap; }
/* Phones: two fields a line, the label and the button each a line of their own. */
@media (max-width: 640px) {
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
.frow .fld.grow { flex:1 1 100%; }
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
.frow .act button { flex:1; }
.frow .act label.check { position:static; transform:none; margin:0; }
.under { margin-right:0; }
.frow .act { width:100%; }
.frow .act button { width:100%; }
}
</style>
<div class="wrap">
@@ -94,21 +91,22 @@
</div>
<div class="act">
<button type="submit">Создать</button>
</div>
</div>
<div class="under"><span class="muted" id="note"></span>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw
</label>
{{end}}
</div>
</div>
<div class="under"><span class="muted" id="note"></span></div>
</form>
</div>
<div class="card">
<h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>http-lp</b> - через соединение агента с панелью (порт {{.PanelPort}}): по WebSocket, а если он недоступен - по HTTP long-poll. Работает везде, где доступна панель, но задержка выше, чем у tcp.</li>
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul>
+75 -9
View File
@@ -152,17 +152,46 @@
<h2>ИИ-ассистент</h2>
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Тип API<select name="ai_type">
<option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
<option value="openai"{{if eq .AI.Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .AI.Type "anthropic"}} selected{{end}}>Anthropic</option>
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
<div class="ai-models">
{{range .AIModels}} <details class="ai-model"><summary class="ai-model-sum"><span class="ai-model-title">{{if .Name}}{{.Name}}{{else}}Модель{{end}}</span><span class="ai-model-sub">{{.Type}}{{if .Model}} · {{.Model}}{{end}}</span></summary>
<div class="ai-model-body">
<input type="hidden" name="m_id" value="{{.ID}}">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
<option value="openai"{{if eq .Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .Type "anthropic"}} selected{{end}}>Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="ai_url" value="{{.AI.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<label>Адрес<input type="url" name="m_url" value="{{.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="{{.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="{{.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
</div></details>
{{end}} </div>
<template class="ai-model-new">
<details class="ai-model" open><summary class="ai-model-sum"><span class="ai-model-title">Новая модель</span><span class="ai-model-sub"></span></summary>
<div class="ai-model-body">
<input type="hidden" name="m_id" value="">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
<option value="openai">OpenAI-совместимый</option>
<option value="anthropic">Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
</template>
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Поиск в интернете</span><span class="ai-model-sub">Запасной - DuckDuckGo</span></summary>
<div class="ai-model-body">
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
@@ -172,6 +201,43 @@
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card" id="autoupdate">
<h2>Автообновление</h2>
<form method="post" action="/settings/autoupdate" class="ai-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{/* The same spoilers as the assistant's. */}}
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление клиентов</span><span class="ai-model-sub">{{if .Auto.AgentOn}}каждые {{.Auto.AgentEvery}} {{if eq .Auto.AgentUnit "d"}}сут.{{else if eq .Auto.AgentUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Клиенты раз в указанное время сверяют свою версию с версией сервера и, если у сервера новее, обновляются сами - так же, как по кнопке «Обновить». Нужны права root или sudo без пароля; агентам старше 0.2.5 один раз обновиться придётся вручную.</p>
<label class="check"><input type="checkbox" name="agent_auto" value="1"{{if .Auto.AgentOn}} checked{{end}}> Включить автообновление клиентов</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="agent_every" min="1" max="999" value="{{.Auto.AgentEvery}}">
<select name="agent_unit"><option value="min"{{if eq .Auto.AgentUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.AgentUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.AgentUnit "d"}} selected{{end}}>суток</option></select></div>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление сервера</span><span class="ai-model-sub">{{if .Auto.ServerOn}}каждые {{.Auto.ServerEvery}} {{if eq .Auto.ServerUnit "d"}}сут.{{else if eq .Auto.ServerUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}} · сейчас {{.Auto.Status.Current}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Сервер раз в указанное время сверяет свою версию с последним релизом на GitLab. Если вышел новый, администратору при входе (после PIN) показывается окно: версия и кнопки «Обновить» и «Отказаться». «Обновить» запускает тот же скрипт, что и вручную: <code>wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh</code> - от root, поэтому нужны root и systemd; панель при этом недоступна около минуты.</p>
<label class="check"><input type="checkbox" name="server_auto" value="1"{{if .Auto.ServerOn}} checked{{end}}> Предлагать обновление сервера при входе</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="server_every" min="1" max="999" value="{{.Auto.ServerEvery}}">
<select name="server_unit"><option value="min"{{if eq .Auto.ServerUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.ServerUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.ServerUnit "d"}} selected{{end}}>суток</option></select></div>
<div class="auto-status muted" aria-live="polite">Версия сервера: <b>{{.Auto.Status.Current}}</b>{{if .Auto.Status.Latest}} · на GitLab: <b>{{.Auto.Status.Latest}}</b>{{end}}{{if .Auto.Status.Checked}} · проверено {{.Auto.Status.Checked}}{{end}}{{if .Auto.Status.Error}}<br><span class="bad">{{.Auto.Status.Error}}</span>{{end}}</div>
<div class="row">
<button type="button" class="btn ghost small auto-check">Проверить сейчас</button>
<button type="button" class="btn small auto-run"{{if not .Auto.Status.Newer}} hidden{{end}}>Обновить сейчас</button>
</div>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
+2 -1
View File
@@ -98,7 +98,8 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
return wsjson.Write(wctx, conn, m)
}
cfg := func() proto.WSMsg {
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()}
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress(),
AutoUpdate: s.agentAutoMinutes(), ServerVersion: Version}
}
if send(cfg()) != nil {
return
+314
View File
@@ -0,0 +1,314 @@
#!/bin/sh
# LPRA server: install or update in one command.
#
# wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
#
# Takes the server binary for this machine from the latest GitLab release and
# the agent binaries (dist/) from the main branch, puts them in /opt/lpra,
# sets up the systemd service and, if asked, nginx with a domain and a
# certificate from certbot. Run again to update: the binary and dist/ are
# replaced, the database, the service's settings and nginx stay.
#
# Questions are asked on the terminal even when the script comes through a
# pipe. Without a terminal the answers come from the environment (or their
# defaults): LPRA_PORT (8091), LPRA_NGINX (y/n), LPRA_DOMAIN, LPRA_EMAIL,
# LPRA_CERTBOT (y/n), LPRA_UFW (y/n). LPRA_REPO and LPRA_DIR change where from
# and where to.
set -eu
REPO=${LPRA_REPO:-itzredtea/lpra}
GL=https://gitlab.com
# The project in the API's paths: its path with "/" as %2F.
API="$GL/api/v4/projects/$(printf '%s' "$REPO" | sed 's|/|%2F|g')"
DIR=${LPRA_DIR:-/opt/lpra}
SERVICE=lpra-server
UNIT=/etc/systemd/system/$SERVICE.service
TUNNEL_PORT=8446
say() { printf '\033[1;34m>>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
# ---------- checks ----------
[ "$(id -u)" -eq 0 ] || die "нужен root: ... | sudo sh"
[ "$(uname -s)" = Linux ] || die "сервер ставится только на Linux"
command -v systemctl >/dev/null 2>&1 || die "нужен systemd"
case "$(uname -m)" in
x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
*) die "архитектура $(uname -m) не поддерживается (есть amd64 и arm64)" ;;
esac
if command -v curl >/dev/null 2>&1; then
dl() { curl -fsSL --retry 3 -o "$2" "$1"; }
fetch() { curl -fsSL --retry 3 "$1"; }
elif command -v wget >/dev/null 2>&1; then
dl() { wget -q -O "$2" "$1"; }
fetch() { wget -q -O - "$1"; }
else
die "нужен curl или wget"
fi
# ---------- questions ----------
TTY=
if (: </dev/tty) 2>/dev/null; then TTY=/dev/tty; fi
# ask VAR "question" default [KEY]: the answer from the terminal, else the
# environment's LPRA_<KEY> (KEY is VAR if not given), else the default.
ask() {
var=$1 q=$2 def=$3 key=${4:-$1}
eval "env=\${LPRA_$key:-}"
[ -n "$env" ] && def=$env
ans=
if [ -n "$TTY" ]; then
if [ -n "$def" ]; then printf '%s [%s]: ' "$q" "$def" >"$TTY"; else printf '%s: ' "$q" >"$TTY"; fi
read -r ans <"$TTY" || ans=
fi
[ -n "$ans" ] || ans=$def
eval "$var=\$ans"
}
# confirm KEY "question" y|n: true for a yes (LPRA_<KEY> without a terminal).
# The answer stays apart from the script's own variables of that name.
confirm() {
ask _yn "$2 (y/n)" "$3" "$1"
case "$_yn" in [yYдД]*) return 0 ;; *) return 1 ;; esac
}
# ---------- download ----------
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT INT TERM
TAG=$(fetch "$API/releases/permalink/latest" 2>/dev/null | tr ',' '\n' |
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || TAG=
if [ -n "$TAG" ]; then
say "последний релиз: $TAG"
BIN_URL="$GL/$REPO/-/releases/$TAG/downloads/lpra-server-linux-$ARCH"
else
warn "не узнать номер релиза у GitLab API - беру latest"
BIN_URL="$GL/$REPO/-/releases/permalink/latest/downloads/lpra-server-linux-$ARCH"
fi
say "скачиваю сервер (linux/$ARCH)"
dl "$BIN_URL" "$TMP/lpra-server" || die "не скачался $BIN_URL"
chmod +x "$TMP/lpra-server"
say "скачиваю dist/ из ветки main"
mkdir -p "$TMP/dist"
# The files of dist/ on main, by the API's list of the folder.
URLS=$(fetch "$API/repository/tree?path=dist&ref=main&per_page=100" 2>/dev/null | tr '{},' '\n\n\n' |
sed -n 's|^"path": *"\(dist/[^"]*\)"$|'"$GL/$REPO"'/-/raw/main/\1|p') || URLS=
if [ -z "$URLS" ]; then
warn "не получить список dist/ у GitLab API - беру агентов linux amd64 и arm64"
URLS="$GL/$REPO/-/raw/main/dist/agent-linux-amd64
$GL/$REPO/-/raw/main/dist/agent-linux-arm64"
fi
for u in $URLS; do
f=${u##*/}
dl "$u" "$TMP/dist/$f" || die "не скачался $u"
case "$f" in agent-*) chmod +x "$TMP/dist/$f" ;; esac
printf ' %s\n' "$f"
done
# ---------- install ----------
UPDATE=
[ -f "$UNIT" ] && UPDATE=1
if [ -n "$UPDATE" ]; then
say "уже установлен - обновляю"
systemctl stop "$SERVICE" 2>/dev/null || true
fi
mkdir -p "$DIR"
mv -f "$TMP/lpra-server" "$DIR/lpra-server"
rm -rf "$DIR/dist.old"
[ -d "$DIR/dist" ] && mv "$DIR/dist" "$DIR/dist.old"
mv "$TMP/dist" "$DIR/dist"
rm -rf "$DIR/dist.old"
say "файлы в $DIR"
# The port: from the existing service on an update, asked otherwise.
PORT=
if [ -n "$UPDATE" ]; then
PORT=$(sed -n 's/.*-addr [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT" | head -n 1)
fi
if [ -z "$PORT" ]; then
ask PORT "порт сервера" 8091
case "$PORT" in '' | *[!0-9]*) die "порт - это число" ;; esac
fi
# ---------- nginx ----------
NGINX= DOMAIN= TLS=
if [ -n "$UPDATE" ]; then NGDEF=n; else NGDEF=y; fi
if confirm NGINX "настроить nginx с доменом" "$NGDEF"; then
if ! command -v nginx >/dev/null 2>&1; then
say "ставлю nginx"
if command -v apt-get >/dev/null 2>&1; then
{ apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx; } >/dev/null 2>&1
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q nginx >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y -q nginx >/dev/null 2>&1
fi
fi
if command -v nginx >/dev/null 2>&1; then
ask DOMAIN "домен (например lpra.example.com)" ""
[ -n "$DOMAIN" ] || die "без домена nginx не настроить"
NGINX=1
if [ -d /etc/nginx/sites-available ]; then
CONF=/etc/nginx/sites-available/lpra
LINK=/etc/nginx/sites-enabled/lpra
else
CONF=/etc/nginx/conf.d/lpra.conf
LINK=
fi
cat >"$CONF" <<EOF
# LPRA (server-install.sh): $DOMAIN -> 127.0.0.1:$PORT
# Agents and terminals use WebSocket (the Upgrade headers) and long polling
# (no buffering, long timeouts).
map \$http_upgrade \$lpra_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name $DOMAIN;
client_max_body_size 120m;
location / {
proxy_pass http://127.0.0.1:$PORT;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$lpra_connection_upgrade;
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
EOF
[ -n "$LINK" ] && ln -sf "$CONF" "$LINK"
if ! nginx -t >/dev/null 2>&1; then nginx -t; die "nginx -t не прошёл, конфиг: $CONF"; fi
systemctl enable --now nginx >/dev/null 2>&1 || true
systemctl reload nginx
say "nginx: $DOMAIN -> 127.0.0.1:$PORT ($CONF)"
# A certificate, when certbot with its nginx plugin is there.
if command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | grep -q nginx; then
if confirm CERTBOT "получить сертификат Let's Encrypt (certbot --nginx)" y; then
ask EMAIL "email для Let's Encrypt (можно пусто)" ""
if [ -n "$EMAIL" ]; then M="-m $EMAIL"; else M="--register-unsafely-without-email"; fi
# shellcheck disable=SC2086
if certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --redirect $M; then
TLS=1
else
warn "certbot не смог - остаётся http. Домен должен указывать на этот сервер, порт 80 открыт"
fi
fi
else
warn "certbot с плагином nginx не найден - сертификат пропускаю (apt install certbot python3-certbot-nginx)"
fi
else
warn "nginx не установлен и не поставился - пропускаю"
fi
fi
# Not set up now, but by an earlier run: its domain, and https if certbot
# added it.
if [ -z "$NGINX" ]; then
for c in /etc/nginx/sites-available/lpra /etc/nginx/conf.d/lpra.conf; do
[ -f "$c" ] || continue
DOMAIN=$(sed -n 's/^[[:space:]]*server_name[[:space:]]\{1,\}\([^;[:space:]]*\).*/\1/p' "$c" | head -n 1)
grep -q 'listen.*443' "$c" && TLS=1
break
done
fi
# ---------- firewall ----------
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
if confirm UFW "ufw включён: открыть 80, 443 и туннель $TUNNEL_PORT tcp/udp" y; then
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw allow $TUNNEL_PORT/tcp >/dev/null
ufw allow $TUNNEL_PORT/udp >/dev/null
if [ -z "$DOMAIN" ]; then ufw allow "$PORT/tcp" >/dev/null; fi
say "ufw: открыто"
fi
fi
# ---------- service ----------
# Behind nginx: local only. Without it the panel listens on every address.
# Cookies are Secure only when the panel is reached by https.
if [ -z "$UPDATE" ] || [ -n "$NGINX" ]; then
if [ -n "$NGINX" ]; then ADDR=127.0.0.1:$PORT; else ADDR=0.0.0.0:$PORT; fi
COOKIES=
[ -z "$TLS" ] && COOKIES='Environment=LPRA_INSECURE_COOKIES=1'
cat >"$UNIT" <<EOF
# LPRA server (server-install.sh). Its database and key: $DIR/lpra.db, $DIR/lpra.db.key
[Unit]
Description=LPRA remote access host (admin panel + agent rendezvous)
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=$DIR
ExecStart=$DIR/lpra-server -addr $ADDR -db $DIR/lpra.db -dist $DIR/dist
$COOKIES
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
# The database, and what ufw writes when the panel opens forwarded ports.
ReadWritePaths=$DIR -/etc/ufw -/run
[Install]
WantedBy=multi-user.target
EOF
fi
systemctl daemon-reload
systemctl enable "$SERVICE" >/dev/null 2>&1
systemctl restart "$SERVICE"
say "служба $SERVICE запущена"
# ---------- health check ----------
ok=
i=0
while [ $i -lt 20 ]; do
if fetch "http://127.0.0.1:$PORT/login" >/dev/null 2>&1; then ok=1; break; fi
i=$((i + 1))
sleep 1
done
[ -n "$ok" ] || die "сервер не отвечает на 127.0.0.1:$PORT - смотрите: journalctl -u $SERVICE -n 50"
say "сервер отвечает"
if [ -n "$NGINX" ]; then
if fetch "http://127.0.0.1/login" >/dev/null 2>&1 || [ -n "$TLS" ]; then say "nginx отвечает"; fi
fi
# ---------- done ----------
if [ -n "$TLS" ]; then URL=https://$DOMAIN
elif [ -n "$DOMAIN" ]; then URL=http://$DOMAIN
else
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
URL="http://${IP:-<IP сервера>}:$PORT"
fi
echo
say "готово: LPRA ${TAG:-} в $DIR"
if [ -z "$UPDATE" ]; then
echo " откройте $URL/setup - мастер создаст администратора"
else
echo " панель: $URL"
fi
if [ -z "$TLS" ]; then echo " панель без https: куки без Secure. Для работы через интернет лучше домен с сертификатом"; fi
echo " логи: journalctl -u $SERVICE -f"