Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf032042b | ||
|
|
01f02360c4 | ||
|
|
57de7bf967 | ||
|
|
173f46a145 | ||
|
|
03f963340e | ||
|
|
37ef886503 | ||
|
|
6c1da79b4a | ||
|
|
6a17f40042 | ||
|
|
351e4ad28d | ||
|
|
983809714b | ||
|
|
ded86e1c3f | ||
|
|
b4bcc05162 | ||
|
|
e8d45f2f7b | ||
|
|
e93a2bbd3a | ||
|
|
e0b3999d0c | ||
|
|
77322e8644 | ||
|
|
6085b5a094 | ||
|
|
e0cc5a06b7 | ||
|
|
667c6177f4 |
No files matched your search
@@ -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
@@ -1 +1 @@
|
||||
0.2.1
|
||||
0.2.2
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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, начат ДД.ММ.ГГГГ» - раз дата создания новая.
|
||||
- Токены, которые светились в чатах и логах, - отозвать и выпустить новые.
|
||||
@@ -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 |
@@ -33,7 +33,8 @@ func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
|
||||
}
|
||||
|
||||
// runForward dials 127.0.0.1:port on the client and bridges it to the session,
|
||||
// giving the host a reverse TCP tunnel over the same long-poll transport.
|
||||
// giving the host a reverse TCP tunnel over the agent's link to the server:
|
||||
// the WebSocket, or the long polls without one.
|
||||
func (a *Agent) runForward(parent context.Context, sid string, port int) {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
@@ -92,7 +93,8 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
}
|
||||
}
|
||||
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// pollIn takes the next input - pushed on the WebSocket, else by a long
|
||||
// poll - and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
// On the WebSocket the input comes pushed; if it drops, by a long poll.
|
||||
|
||||
@@ -1,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
@@ -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,16 +112,37 @@ 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}
|
||||
|
||||
// aiStream asks the model for the answer to msgs (the last one the user's)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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">' +
|
||||
|
||||
@@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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';
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 на хосте.'
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Executable
+314
@@ -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"
|
||||
Reference in new issue
Block a user