19 Commits
Author SHA1 Message Date
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
30 changed files with 1442 additions and 100 deletions

No files matched your search

+34 -7
View File
@@ -45,8 +45,11 @@
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
ответом). Модель задаёт администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию -
прокси к Qwen Omni), OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
название в чате (и короткое, до 12 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
можно показывать выбор всегда.
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
@@ -92,6 +95,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 +185,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 +213,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.2
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

+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.
+6 -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"`
+355 -44
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,63 @@ 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 }
// 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 +112,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 +176,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 +246,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 +287,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 +311,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 +326,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 +353,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 +389,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 +474,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 +491,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 {
@@ -647,6 +846,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 +856,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 +922,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 +932,53 @@ 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, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
}
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 +1001,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 +1055,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 +1136,17 @@ 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})
}
writeJSON(w, map[string]any{"on": on != "0", "models": ms})
}
// POST /api/panel/ai/toggle - {on}: the user's own switch.
@@ -893,15 +1173,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" {
@@ -1001,3 +1305,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)
}
}
+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)
}
}
+5 -3
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)
+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)
+1 -1
View File
@@ -443,7 +443,7 @@ 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(),
"DBNow": dbNow,
"DBChosen": dbChosen,
+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 {
+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
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">' +
+84 -9
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,12 @@
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 = [];
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
loadState();
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
@@ -204,7 +214,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();
@@ -647,14 +656,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 +698,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 */ }
@@ -722,7 +785,6 @@
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 +851,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 +887,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';
}
+48 -15
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);
@@ -896,30 +896,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 +945,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; }
@@ -959,8 +957,35 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
.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 +1006,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; }
@@ -1066,8 +1091,8 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
.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 { display:inline-flex; align-items:baseline; gap:6px; min-width:0; color:var(--muted); }
.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;
@@ -1099,6 +1124,14 @@ 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 { display:grid; gap:10px; min-width:0; margin:0; padding:12px; border:1px solid var(--border); border-radius:10px; }
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:12px 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. */
+15 -1
View File
@@ -23,6 +23,20 @@
}, e => alert('Не скопировалось: ' + e.message));
});
if (window.UsersTable) window.UsersTable.mount(root);
// ИИ-ассистент: 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();
});
}
}
});
@@ -75,7 +89,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');
+1 -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);
}
+1 -1
View File
@@ -108,7 +108,7 @@
<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>
+34 -7
View File
@@ -152,15 +152,42 @@
<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}} <fieldset class="ai-model">
<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>
</fieldset>
{{end}} </div>
<template class="ai-model-new">
<fieldset class="ai-model">
<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>
</fieldset>
</template>
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
+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"