Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
916a49958b | ||
|
|
dde85031aa | ||
|
|
dd1244a169 | ||
|
|
c72fcfebf3 | ||
|
|
054fc97da8 | ||
|
|
114f8b1e1b | ||
|
|
a1604206ee | ||
|
|
a107745355 | ||
|
|
dcbc602d88 | ||
|
|
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,11 +45,21 @@
|
|||||||
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
|
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
|
||||||
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
||||||
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
||||||
ответом). Модель задаёт администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию -
|
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
|
||||||
прокси к Qwen Omni), OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
|
название в чате (и короткое, до 12 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
|
||||||
|
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
|
||||||
|
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
|
||||||
|
можно показывать выбор всегда.
|
||||||
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
|
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
|
||||||
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
|
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
|
||||||
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
|
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
|
||||||
|
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
|
||||||
|
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
|
||||||
|
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
|
||||||
|
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
|
||||||
|
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
|
||||||
|
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
|
||||||
|
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
|
||||||
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
|
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
|
||||||
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
|
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
|
||||||
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
|
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
|
||||||
@@ -92,6 +102,29 @@ PostgreSQL: хост, порт, база, пользователь, пароль
|
|||||||
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
||||||
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
|
Версия задаётся в одном месте - `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
|
```sh
|
||||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||||
@@ -159,7 +192,8 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
|
|||||||
- **lp** - только long-polling.
|
- **lp** - только long-polling.
|
||||||
|
|
||||||
## Проброс портов и прямой туннель
|
## Проброс портов и прямой туннель
|
||||||
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
|
Правила создаются на странице машины «Порты». Режим `http` (в панели - http-lp) идёт через
|
||||||
|
соединение агента с панелью: по WebSocket, а если он недоступен - по long-polling;
|
||||||
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
||||||
по нему мультиплексируются все соединения.
|
по нему мультиплексируются все соединения.
|
||||||
|
|
||||||
@@ -186,10 +220,10 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
|
|||||||
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
|
- Открытие терминала и 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.4
|
||||||
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,
|
// 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) {
|
func (a *Agent) runForward(parent context.Context, sid string, port int) {
|
||||||
d := net.Dialer{Timeout: 10 * time.Second}
|
d := net.Dialer{Timeout: 10 * time.Second}
|
||||||
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
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.
|
// 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 {
|
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.
|
// 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.
|
// 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
|
// All agent<->server traffic is plain JSON, on a WebSocket the agent opens or,
|
||||||
// survives NAT and restrictive egress firewalls: the agent only ever makes
|
// where that does not get through, over HTTP(S) long polls, so it survives
|
||||||
// outbound requests.
|
// NAT and restrictive egress firewalls: the agent only ever connects out.
|
||||||
package proto
|
package proto
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
@@ -11,7 +11,7 @@ import "encoding/json"
|
|||||||
|
|
||||||
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
||||||
const (
|
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"
|
CtrlIdle = "idle"
|
||||||
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
|
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
|
||||||
CtrlOpenTerminal = "open_terminal"
|
CtrlOpenTerminal = "open_terminal"
|
||||||
@@ -56,7 +56,8 @@ type EnrollResponse struct {
|
|||||||
Token string `json:"token"`
|
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 ControlMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
|||||||
+377
-54
@@ -27,20 +27,81 @@ import (
|
|||||||
// and never sent to the browser - and streams the answer back as text.
|
// and never sent to the browser - and streams the answer back as text.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cfgAIType = "ai_type" // gradio, openai or anthropic
|
// cfgAIModels: the models to choose from, as JSON - the first is the
|
||||||
cfgAIURL = "ai_url"
|
// default. The four keys after it are the one model of before.
|
||||||
cfgAIKey = "ai_key"
|
cfgAIModels = "ai_models"
|
||||||
cfgAIModel = "ai_model"
|
cfgAIType = "ai_type" // gradio, openai or anthropic
|
||||||
|
cfgAIURL = "ai_url"
|
||||||
|
cfgAIKey = "ai_key"
|
||||||
|
cfgAIModel = "ai_model"
|
||||||
|
|
||||||
// DefaultAIURL is the proxy to the Qwen space the assistant uses by default.
|
// DefaultAIURL is the proxy to the Qwen space the assistant uses by default.
|
||||||
DefaultAIURL = "https://agentproxy.minomix.ru"
|
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
|
// 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.
|
// default; an explicit "0" means the user turned it off.
|
||||||
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
|
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
|
||||||
|
|
||||||
type aiConfig struct{ Type, URL, Key, Model string }
|
// aiOn: the user has the assistant - on unless turned off.
|
||||||
|
func (s *Server) aiOn(id int64) bool {
|
||||||
|
v, _ := s.db.GetConfig(aiUserKey(id))
|
||||||
|
return v != "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiConfig is one model the assistant can ask: its id and the names the chat
|
||||||
|
// shows (Short, up to 12 characters, on the button of the model in use), the
|
||||||
|
// kind of API, its address, model and key.
|
||||||
|
type aiConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Short string `json:"short,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
|
||||||
|
// think, for one request: "on" asks for the model's own thinking, "off"
|
||||||
|
// asks it not to think, "" leaves it to the model.
|
||||||
|
think string
|
||||||
|
}
|
||||||
|
|
||||||
|
// label is the model's name in the chat: its own, else its model or kind.
|
||||||
|
func (c aiConfig) label() string {
|
||||||
|
switch {
|
||||||
|
case c.Name != "":
|
||||||
|
return c.Name
|
||||||
|
case c.Model != "":
|
||||||
|
return c.Model
|
||||||
|
case c.Type == "openai":
|
||||||
|
return "OpenAI"
|
||||||
|
case c.Type == "anthropic":
|
||||||
|
return "Anthropic"
|
||||||
|
}
|
||||||
|
return "Qwen Omni"
|
||||||
|
}
|
||||||
|
|
||||||
|
// tidy keeps a known kind and gives gradio its default address.
|
||||||
|
func (c aiConfig) tidy() aiConfig {
|
||||||
|
if c.Type != "openai" && c.Type != "anthropic" {
|
||||||
|
c.Type = "gradio"
|
||||||
|
}
|
||||||
|
c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key)
|
||||||
|
if r := []rune(strings.TrimSpace(c.Short)); len(r) > 12 {
|
||||||
|
c.Short = strings.TrimSpace(string(r[:12]))
|
||||||
|
} else {
|
||||||
|
c.Short = string(r)
|
||||||
|
}
|
||||||
|
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
|
||||||
|
if c.URL == "" && c.Type == "gradio" {
|
||||||
|
c.URL = DefaultAIURL
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
type aiAttachment struct {
|
type aiAttachment struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -57,16 +118,37 @@ type aiMsg struct {
|
|||||||
ThinkMS int64 `json:"think_ms,omitempty"`
|
ThinkMS int64 `json:"think_ms,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) aiConfig() aiConfig {
|
// aiModels are the models set in Настройки, never none: before any is
|
||||||
get := func(k, def string) string {
|
// saved, the one of the older settings (or the default).
|
||||||
if v, _ := s.db.GetConfig(k); v != "" {
|
func (s *Server) aiModels() []aiConfig {
|
||||||
return v
|
var ms []aiConfig
|
||||||
}
|
if v, _ := s.db.GetConfig(cfgAIModels); v != "" {
|
||||||
return def
|
_ = json.Unmarshal([]byte(v), &ms)
|
||||||
}
|
}
|
||||||
return aiConfig{Type: get(cfgAIType, "gradio"), URL: strings.TrimRight(get(cfgAIURL, DefaultAIURL), "/"), Key: get(cfgAIKey, ""), Model: get(cfgAIModel, "")}
|
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)}}
|
||||||
|
}
|
||||||
|
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}
|
var aiClient = &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
|
||||||
// aiStream asks the model for the answer to msgs (the last one the user's)
|
// aiStream asks the model for the answer to msgs (the last one the user's)
|
||||||
@@ -100,6 +182,59 @@ func sseData(r io.Reader, fn func(data string) (stop bool, err error)) error {
|
|||||||
return sc.Err()
|
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) {
|
func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*http.Response, error) {
|
||||||
b, _ := json.Marshal(body)
|
b, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||||
@@ -117,7 +252,7 @@ func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*
|
|||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
|
||||||
resp.Body.Close()
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -158,10 +293,21 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
if cfg.Key != "" {
|
if cfg.Key != "" {
|
||||||
hdr["Authorization"] = "Bearer " + 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tw := &thinkWrap{emit: emit}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return sseData(resp.Body, func(d string) (bool, error) {
|
return sseData(resp.Body, func(d string) (bool, error) {
|
||||||
if d == "[DONE]" {
|
if d == "[DONE]" {
|
||||||
@@ -170,7 +316,9 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
var m struct {
|
var m struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
@@ -184,9 +332,8 @@ func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
return true, errors.New(m.Error.Message)
|
return true, errors.New(m.Error.Message)
|
||||||
}
|
}
|
||||||
for _, c := range m.Choices {
|
for _, c := range m.Choices {
|
||||||
if c.Delta.Content != "" {
|
tw.think(c.Delta.ReasoningContent + c.Delta.Reasoning)
|
||||||
emit(c.Delta.Content)
|
tw.text(c.Delta.Content)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
})
|
})
|
||||||
@@ -212,17 +359,32 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
|
|||||||
}
|
}
|
||||||
apiMessages = append(apiMessages, map[string]any{"role": m.Role, "content": content})
|
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},
|
body := 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"})
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
tw := &thinkWrap{emit: emit}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return sseData(resp.Body, func(d string) (bool, error) {
|
return sseData(resp.Body, func(d string) (bool, error) {
|
||||||
var m struct {
|
var m struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Text string `json:"text"`
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
Error struct {
|
Error struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -233,7 +395,11 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
|
|||||||
}
|
}
|
||||||
switch m.Type {
|
switch m.Type {
|
||||||
case "content_block_delta":
|
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":
|
case "message_stop":
|
||||||
return true, nil
|
return true, nil
|
||||||
case "error":
|
case "error":
|
||||||
@@ -314,10 +480,15 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var conf struct {
|
var conf struct {
|
||||||
|
Components []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"components"`
|
||||||
Dependencies []struct {
|
Dependencies []struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
APIName string `json:"api_name"`
|
APIName string `json:"api_name"`
|
||||||
Targets [][]any `json:"targets"`
|
Targets [][]any `json:"targets"`
|
||||||
|
Inputs []int `json:"inputs"`
|
||||||
} `json:"dependencies"`
|
} `json:"dependencies"`
|
||||||
}
|
}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&conf)
|
err = json.NewDecoder(resp.Body).Decode(&conf)
|
||||||
@@ -326,45 +497,79 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
|
|||||||
return errors.New("модель: не прочиталась её конфигурация")
|
return errors.New("модель: не прочиталась её конфигурация")
|
||||||
}
|
}
|
||||||
fn, trigger := -1, any(nil)
|
fn, trigger := -1, any(nil)
|
||||||
|
var inputs []int
|
||||||
for _, d := range conf.Dependencies {
|
for _, d := range conf.Dependencies {
|
||||||
if d.APIName == "chat_predict" {
|
if d.APIName == "chat_predict" {
|
||||||
fn = d.ID
|
fn = d.ID
|
||||||
|
inputs = d.Inputs
|
||||||
if len(d.Targets) > 0 && len(d.Targets[0]) > 0 {
|
if len(d.Targets) > 0 && len(d.Targets[0]) > 0 {
|
||||||
trigger = 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 {
|
if fn < 0 {
|
||||||
return errors.New("модель: нет функции chat_predict")
|
return errors.New("модель: нет функции chat_predict")
|
||||||
}
|
}
|
||||||
last := msgs[len(msgs)-1]
|
last := msgs[len(msgs)-1]
|
||||||
media := []any{nil, nil, nil}
|
media := map[string]any{}
|
||||||
for _, a := range last.Attachments {
|
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
|
continue
|
||||||
}
|
}
|
||||||
slot := -1
|
fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
|
||||||
switch {
|
if err != nil {
|
||||||
case strings.HasPrefix(a.MIME, "image/"):
|
return err
|
||||||
slot = 0
|
|
||||||
case strings.HasPrefix(a.MIME, "audio/"):
|
|
||||||
slot = 1
|
|
||||||
case strings.HasPrefix(a.MIME, "video/"):
|
|
||||||
slot = 2
|
|
||||||
}
|
}
|
||||||
if slot >= 0 {
|
if kind == "video" {
|
||||||
fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
|
// Gradio 5 takes a video with its subtitles.
|
||||||
if err != nil {
|
media[kind] = map[string]any{"video": fileData, "subtitles": nil}
|
||||||
return err
|
} 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:]
|
||||||
}
|
}
|
||||||
media[slot] = fileData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
session := hex.EncodeToString(b)
|
session := hex.EncodeToString(b)
|
||||||
jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{
|
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,
|
"fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session,
|
||||||
}, hdr)
|
}, hdr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -558,6 +763,7 @@ func (s *Server) aiDeleteChat(owner, chat int64) {
|
|||||||
}
|
}
|
||||||
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner)
|
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner)
|
||||||
_, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner)
|
_, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner)
|
||||||
|
s.db.wipeDeleted()
|
||||||
}
|
}
|
||||||
|
|
||||||
// aiAppendTo keeps a message of owner's chat; the first question names it.
|
// aiAppendTo keeps a message of owner's chat; the first question names it.
|
||||||
@@ -576,6 +782,7 @@ func (s *Server) aiKeepMsg(owner, chat int64, m aiMsg) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
|
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
|
||||||
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
|
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
|
||||||
|
s.db.wipeDeleted()
|
||||||
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
|
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
|
||||||
if role == "user" {
|
if role == "user" {
|
||||||
t := strings.Join(strings.Fields(content), " ")
|
t := strings.Join(strings.Fields(content), " ")
|
||||||
@@ -634,7 +841,7 @@ func (s *Server) aiAdopt(owner int64) {
|
|||||||
// text with "[ошибка: …]".
|
// text with "[ошибка: …]".
|
||||||
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||||
a := s.currentAdmin(r)
|
a := s.currentAdmin(r)
|
||||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on == "0" {
|
if !s.aiOn(a.ID) {
|
||||||
http.Error(w, "ассистент выключен в профиле", http.StatusForbidden)
|
http.Error(w, "ассистент выключен в профиле", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -647,6 +854,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
Search bool `json:"search"`
|
Search bool `json:"search"`
|
||||||
Chat int64 `json:"chat"`
|
Chat int64 `json:"chat"`
|
||||||
Attachments []aiAttachment `json:"attachments"`
|
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) {
|
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)
|
http.Error(w, "плохой запрос", http.StatusBadRequest)
|
||||||
@@ -656,7 +864,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "можно прикрепить не больше 6 файлов", http.StatusBadRequest)
|
http.Error(w, "можно прикрепить не больше 6 файлов", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := s.aiConfig()
|
cfg := s.aiModel(in.Model)
|
||||||
total := 0
|
total := 0
|
||||||
mediaCounts := map[string]int{}
|
mediaCounts := map[string]int{}
|
||||||
for i := range in.Attachments {
|
for i := range in.Attachments {
|
||||||
@@ -722,7 +930,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.aiAppendTo(a.ID, in.Chat, "user", stored)
|
s.aiAppendTo(a.ID, in.Chat, "user", stored)
|
||||||
if fresh {
|
if fresh {
|
||||||
// A new chat gets a short name from the model, aside.
|
// 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)
|
msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000)
|
||||||
if len(msgs) > 0 && msgs[len(msgs)-1].Role == "user" {
|
if len(msgs) > 0 && msgs[len(msgs)-1].Role == "user" {
|
||||||
@@ -732,19 +940,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
|
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
|
||||||
}
|
}
|
||||||
system := aiSystem
|
system := aiSystem
|
||||||
|
// Pictures, sound and video go to the model itself: it is told so, or
|
||||||
|
// it takes them for something a panel assistant does not do.
|
||||||
|
var media []string
|
||||||
|
for _, f := range in.Attachments {
|
||||||
|
if f.Data != "" {
|
||||||
|
media = append(media, f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(media) > 0 {
|
||||||
|
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
|
||||||
|
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
|
||||||
|
}
|
||||||
|
system += s.imagePrompt()
|
||||||
if in.Agent {
|
if in.Agent {
|
||||||
system += aiAgent
|
system += aiAgent
|
||||||
}
|
}
|
||||||
if in.Search {
|
if in.Search {
|
||||||
system += searchPrompt(s.searchConf())
|
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
|
system += aiThink
|
||||||
// Models heed the last words most: the reminder goes there too (not
|
// Models heed the last words most: the reminder goes there too (not
|
||||||
// kept with the talk).
|
// kept with the talk).
|
||||||
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
|
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
|
||||||
msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)"
|
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("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
@@ -767,7 +1010,40 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
fl.Flush()
|
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
|
// The answer is kept whole, thinking too (to show it again); the model
|
||||||
// gets it without (aiWindow).
|
// gets it without (aiWindow).
|
||||||
ans := aiMsg{Role: "assistant", Content: answer.String()}
|
ans := aiMsg{Role: "assistant", Content: answer.String()}
|
||||||
@@ -788,14 +1064,17 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// aiNameChat asks the model for a two-or-three-word name of the chat its
|
// aiNameChat asks the model for a two-or-three-word name of the chat its
|
||||||
// first question opened, and gives the chat that name.
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if r := []rune(question); len(r) > 600 {
|
if r := []rune(question); len(r) > 600 {
|
||||||
question = string(r[:600])
|
question = string(r[:600])
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
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) })
|
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ai: naming chat %d: %v", chat, err)
|
log.Printf("ai: naming chat %d: %v", chat, err)
|
||||||
@@ -866,7 +1145,18 @@ func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// GET /api/panel/ai - whether the assistant is on for this user.
|
// GET /api/panel/ai - whether the assistant is on for this user.
|
||||||
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
|
||||||
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
|
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
|
||||||
writeJSON(w, map[string]any{"on": on != "0"})
|
// The models to choose from: ids and names, never a key.
|
||||||
|
type pick struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Short string `json:"short,omitempty"`
|
||||||
|
}
|
||||||
|
var ms []pick
|
||||||
|
for _, m := range s.aiModels() {
|
||||||
|
ms = append(ms, pick{m.ID, m.label(), m.Short})
|
||||||
|
}
|
||||||
|
_, imgKey := s.imageConf()
|
||||||
|
writeJSON(w, map[string]any{"on": on != "0", "models": ms, "image": imgKey != ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/panel/ai/toggle - {on}: the user's own switch.
|
// POST /api/panel/ai/toggle - {on}: the user's own switch.
|
||||||
@@ -893,15 +1183,39 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
typ := r.FormValue("ai_type")
|
// The models, a card each, in order. The key field shows the key there;
|
||||||
if typ != "openai" && typ != "anthropic" {
|
// what it holds is the key (empty: none). A card left empty is dropped;
|
||||||
typ = "gradio"
|
// 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 «Поиск».
|
// The search engine for «Поиск».
|
||||||
eng := r.FormValue("ai_search")
|
eng := r.FormValue("ai_search")
|
||||||
if eng != "searxng" && eng != "brave" && eng != "tavily" {
|
if eng != "searxng" && eng != "brave" && eng != "tavily" {
|
||||||
@@ -910,6 +1224,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = s.db.SetConfig(cfgSearchEngine, eng)
|
_ = s.db.SetConfig(cfgSearchEngine, eng)
|
||||||
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
|
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
|
||||||
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
|
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
|
||||||
|
// The key of the image service («Генерация изображений»).
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, strings.TrimSpace(r.FormValue("ai_image_key")))
|
||||||
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,3 +1317,10 @@ func stripThink(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -393,3 +394,42 @@ func TestAIChatTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatalf("title: %+v", s.aiChats(a.ID))
|
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,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The assistant is on unless the user turned it off - for the web search
|
||||||
|
// and page reading as for the chat (a user who never touched the switch
|
||||||
|
// has no setting at all).
|
||||||
|
func TestAISearchAndFetchOnByDefault(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
sx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"results":[{"title":"Intel","url":"https://example.com/intel","content":"процессоры"}]}`)
|
||||||
|
}))
|
||||||
|
defer sx.Close()
|
||||||
|
_ = s.db.SetConfig(cfgSearchEngine, "searxng")
|
||||||
|
_ = s.db.SetConfig(cfgSearchURL, sx.URL)
|
||||||
|
post := func(path, body string) string {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.AddCookie(c)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
if got := post("/api/panel/ai/search", `{"q":"intel"}`); !strings.Contains(got, `"ok":true`) {
|
||||||
|
t.Fatalf("search by default: %s", got)
|
||||||
|
}
|
||||||
|
if got := post("/api/panel/ai/fetch", `{"url":"не адрес"}`); strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("fetch by default: %s", got)
|
||||||
|
}
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||||
|
for _, p := range []string{"/api/panel/ai/search", "/api/panel/ai/fetch"} {
|
||||||
|
if got := post(p, `{"q":"intel","url":"https://example.com"}`); !strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("%s with the assistant off: %s", p, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A chat deleted takes with it what the user attached to it: the text of
|
||||||
|
// its files lives in the messages, and must be gone from the database
|
||||||
|
// files too - not just from the tables (SQLite leaves deleted rows in its
|
||||||
|
// free pages and in the write-ahead log).
|
||||||
|
func TestDeleteChatWipesAttachments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "t.db")
|
||||||
|
db, err := OpenDB(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := New(Options{DB: db, DistDir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("Passw0rd12345")
|
||||||
|
if err := db.CreateAdmin("adm", hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adm, _ := db.AdminByUsername("adm")
|
||||||
|
me := adm.ID
|
||||||
|
const secret = "TOP-SECRET-CONTENT-OF-AN-ATTACHED-FILE-7731"
|
||||||
|
chat := s.aiNewChat(me)
|
||||||
|
other := s.aiNewChat(me)
|
||||||
|
s.aiAppendTo(me, chat, "user", "посмотри\n\n[Вложение: notes.txt]\nСодержимое файла:\n"+secret)
|
||||||
|
s.aiAppendTo(me, chat, "result", "Картинка готова [картинка: https://s2.x-grk.com/uploads/userphoto/ai/zzz-7731.png]")
|
||||||
|
s.aiAppendTo(me, other, "user", "другой чат, остаётся")
|
||||||
|
|
||||||
|
s.aiDeleteChat(me, chat)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, chat).Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("%d messages of the deleted chat remain", n)
|
||||||
|
}
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, other).Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("the other chat has %d messages, want 1", n)
|
||||||
|
}
|
||||||
|
for _, f := range []string{path, path + "-wal"} {
|
||||||
|
b, _ := os.ReadFile(f)
|
||||||
|
if bytes.Contains(b, []byte(secret)) || bytes.Contains(b, []byte("zzz-7731")) {
|
||||||
|
t.Fatalf("the deleted chat's content is still in %s", filepath.Base(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The oldest chats go past the limit the same way: nothing of them is kept.
|
||||||
|
func TestOldChatsWipedToo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "t.db")
|
||||||
|
db, err := OpenDB(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := New(Options{DB: db, DistDir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("Passw0rd12345")
|
||||||
|
if err := db.CreateAdmin("adm", hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adm, _ := db.AdminByUsername("adm")
|
||||||
|
me := adm.ID
|
||||||
|
first := s.aiNewChat(me)
|
||||||
|
s.aiAppendTo(me, first, "user", "[Вложение: a.txt]\nСодержимое файла:\nOLD-ATTACHMENT-TEXT-5512")
|
||||||
|
for i := 0; i < aiChatsKeep; i++ {
|
||||||
|
s.aiNewChat(me)
|
||||||
|
}
|
||||||
|
if s.aiOwns(me, first) {
|
||||||
|
t.Fatal("the oldest chat is kept past the limit")
|
||||||
|
}
|
||||||
|
for _, f := range []string{path, path + "-wal"} {
|
||||||
|
b, _ := os.ReadFile(f)
|
||||||
|
if bytes.Contains(b, []byte("OLD-ATTACHMENT-TEXT-5512")) {
|
||||||
|
t.Fatalf("the old chat's content is still in %s", filepath.Base(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -20,7 +20,7 @@ type DB struct {
|
|||||||
func OpenDB(path string) (*DB, error) {
|
func OpenDB(path string) (*DB, error) {
|
||||||
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
||||||
// long-poll load; foreign_keys keeps the schema honest.
|
// long-poll load; foreign_keys keeps the schema honest.
|
||||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
|
||||||
sdb, err := sql.Open("sqlite", dsn)
|
sdb, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -754,3 +754,15 @@ func boolInt(b bool) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wipeDeleted makes what was deleted leave the database's files: with
|
||||||
|
// secure_delete the freed pages are overwritten, and the write-ahead log,
|
||||||
|
// which still holds the rows as they were written, is emptied (SQLite only;
|
||||||
|
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
|
||||||
|
// among that.
|
||||||
|
func (db *DB) wipeDeleted() {
|
||||||
|
if db.sql.pg {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||||
|
}
|
||||||
@@ -179,7 +179,7 @@ func pageText(page string) (string, string) {
|
|||||||
// assistant on.
|
// assistant on.
|
||||||
func (s *Server) handleAIFetch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAIFetch(w http.ResponseWriter, r *http.Request) {
|
||||||
a := s.currentAdmin(r)
|
a := s.currentAdmin(r)
|
||||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
if !s.aiOn(a.ID) {
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-12
@@ -12,8 +12,9 @@ import (
|
|||||||
|
|
||||||
// forwardManager owns the host-side listeners for port-forward rules. It
|
// forwardManager owns the host-side listeners for port-forward rules. It
|
||||||
// supports three transports:
|
// supports three transports:
|
||||||
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
|
// - "http": over the agent's own link to the panel - its WebSocket, or the
|
||||||
// reaches, highest latency);
|
// HTTP long polls while there is none (works anywhere nginx reaches,
|
||||||
|
// highest latency);
|
||||||
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
|
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
|
||||||
// - "udp": direct UDP over the tunnel port.
|
// - "udp": direct UDP over the tunnel port.
|
||||||
type forwardManager struct {
|
type forwardManager struct {
|
||||||
@@ -103,7 +104,8 @@ func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
|
|||||||
at.openStream(rule.ClientPort, 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) {
|
func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
||||||
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
|
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
|
||||||
defer m.hub.CloseSession(sess.ID)
|
defer m.hub.CloseSession(sess.ID)
|
||||||
@@ -127,20 +129,39 @@ func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// write passes one message on; false: the stream is over.
|
||||||
|
write := func(out proto.OutMessage) bool {
|
||||||
|
if out.Exit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if out.Data != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(out.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(b); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sess.Done():
|
|
||||||
return
|
|
||||||
case out, ok := <-sess.Out():
|
case out, ok := <-sess.Out():
|
||||||
if !ok || out.Exit {
|
if !ok || !write(out) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out.Data != "" {
|
case <-sess.Done():
|
||||||
b, err := base64.StdEncoding.DecodeString(out.Data)
|
// The agent's end of stream closes the session at once, while
|
||||||
if err != nil {
|
// what it sent before may still be queued (a download read
|
||||||
return
|
// faster than the browser takes it): that goes out first.
|
||||||
}
|
for {
|
||||||
if _, err := conn.Write(b); err != nil {
|
select {
|
||||||
|
case out := <-sess.Out():
|
||||||
|
if !write(out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An http-lp download the agent has finished (its last chunk, then the end)
|
||||||
|
// reaches the browser whole: the session closing does not cut off what is
|
||||||
|
// still queued for it.
|
||||||
|
func TestForwardHTTPDrainsAfterClose(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
m := &forwardManager{hub: hub}
|
||||||
|
user, host := net.Pipe()
|
||||||
|
go m.bridgeHTTP(ForwardRule{AgentID: "a1", ClientPort: 80, Proto: "http"}, host)
|
||||||
|
|
||||||
|
var sess *Session
|
||||||
|
for i := 0; i < 100 && sess == nil; i++ {
|
||||||
|
hub.mu.Lock()
|
||||||
|
for _, s := range hub.sessions {
|
||||||
|
sess = s
|
||||||
|
}
|
||||||
|
hub.mu.Unlock()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if sess == nil {
|
||||||
|
t.Fatal("no session")
|
||||||
|
}
|
||||||
|
want := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64 KiB in 64 chunks
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < len(want); i += 1024 {
|
||||||
|
sess.PushOut(proto.OutMessage{Data: base64.StdEncoding.EncodeToString(want[i : i+1024])})
|
||||||
|
}
|
||||||
|
sess.PushOut(proto.OutMessage{Exit: true})
|
||||||
|
sess.close() // as the server does on the agent's end of stream
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond) // all of it queued, the session closed, before the browser reads
|
||||||
|
_ = user.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
got, _ := io.ReadAll(user)
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("got %d of %d bytes", len(got), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`) {
|
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)
|
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"}`)
|
post(`{"openHere":"sometimes"}`)
|
||||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") {
|
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)
|
t.Fatalf("an unknown openHere was kept: %s", got)
|
||||||
|
|||||||
@@ -443,8 +443,9 @@ 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),
|
"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),
|
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"AI": s.aiConfig(),
|
"AIModels": s.aiModels(),
|
||||||
"Search": s.searchConf(),
|
"Search": s.searchConf(),
|
||||||
|
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
|
||||||
"DBNow": dbNow,
|
"DBNow": dbNow,
|
||||||
"DBChosen": dbChosen,
|
"DBChosen": dbChosen,
|
||||||
"UfwInstalled": ufwInstalled,
|
"UfwInstalled": ufwInstalled,
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
|
// Hub is the in-memory broker between agents (on a WebSocket, or by HTTP
|
||||||
// It carries three kinds of work to an agent through its single control poll:
|
// 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).
|
// terminal sessions, port-forward streams, and one-shot tasks (file manager).
|
||||||
// Nothing here is persisted.
|
// Nothing here is persisted.
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pictures for the assistant: the model asks for one in its answer - a
|
||||||
|
// ```image block, a new picture or an edit of a photo - and the panel makes
|
||||||
|
// it through the Vertex API (https://s2.x-grk.com/api/vertex/docs). The
|
||||||
|
// work is a job there: it is put, then asked about until it is done. The
|
||||||
|
// API key is the admins', set in Настройки and never sent to the browser.
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfgImageKey = "ai_image_key"
|
||||||
|
cfgImageURL = "ai_image_url" // not in the settings: the service's address, for tests
|
||||||
|
|
||||||
|
// DefaultImageURL is where the Vertex API is.
|
||||||
|
DefaultImageURL = "https://s2.x-grk.com"
|
||||||
|
|
||||||
|
imageMaxBytes = 20 << 20 // what the service takes of one photo
|
||||||
|
)
|
||||||
|
|
||||||
|
// imageConf is the service's address and the key (none: pictures are off).
|
||||||
|
func (s *Server) imageConf() (base, key string) {
|
||||||
|
base, _ = s.db.GetConfig(cfgImageURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultImageURL
|
||||||
|
}
|
||||||
|
key, _ = s.db.GetConfig(cfgImageKey)
|
||||||
|
return strings.TrimRight(base, "/"), strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagePrompt tells the model it can make pictures, and how - when there
|
||||||
|
// is a key.
|
||||||
|
func (s *Server) imagePrompt() string {
|
||||||
|
if _, key := s.imageConf(); key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n\nКартинки. Ты умеешь рисовать и править фото: сам, по смыслу разговора, без просьбы «вызови инструмент» и без лишних вопросов, " +
|
||||||
|
"когда пользователь просит нарисовать, сгенерировать, изобразить, сделать картинку, арт, логотип, обои, иллюстрацию " +
|
||||||
|
"или изменить, дорисовать, убрать, заменить что-то на фото.\n" +
|
||||||
|
"```image\nописание картинки\n``` - создать новую. Опиши сцену, стиль, свет, детали; язык описания любой.\n" +
|
||||||
|
"```image edit\nчто изменить на фото\n``` - править фото пользователя: то, что приложено к его сообщению " +
|
||||||
|
"(приложено несколько - после edit назови файл: ```image edit имя.jpg```); фото нет - последнюю созданную картинку.\n" +
|
||||||
|
"В первой строке блока можно задать формат: 1:1 (по умолчанию), 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, auto - и качество: " +
|
||||||
|
"fast (по умолчанию, 1 кредит), artist (5, лучше), 0.2plus (10, лучшее); повышай качество, только если пользователь просит. " +
|
||||||
|
"Например: ```image 16:9 artist```.\n" +
|
||||||
|
"Сервис по умолчанию сам улучшает описание (дописывает детали и стиль) - это хорошо для короткого запроса. " +
|
||||||
|
"Если пользователь дал готовое, точное описание (промпт целиком, логотип «ровно как сказано», текст на картинке, строгая композиция) " +
|
||||||
|
"или просит ничего не добавлять - добавь noenhance: ```image noenhance 3:2```. Для правки фото это не применяется.\n" +
|
||||||
|
"Картинку покажет сама панель: ссылку в ответ не вставляй, скажи коротко, что получилось, и предложи поправить. " +
|
||||||
|
"Делается она до пары минут. Одно действие за ответ."
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageError says in Russian what an error code of the service means.
|
||||||
|
func imageError(code, msg string) string {
|
||||||
|
switch code {
|
||||||
|
case "unauthorized":
|
||||||
|
return "сервис не принял API-ключ - проверьте его в Настройки › ИИ-ассистент › Генерация изображений"
|
||||||
|
case "banned":
|
||||||
|
return "аккаунт сервиса картинок заблокирован"
|
||||||
|
case "insufficient_credits":
|
||||||
|
return "на аккаунте сервиса картинок не хватает кредитов"
|
||||||
|
case "daily_limit":
|
||||||
|
return "исчерпан дневной лимит картинок"
|
||||||
|
case "job_busy":
|
||||||
|
return "уже делается другая картинка - дождитесь её"
|
||||||
|
case "invalid_prompt":
|
||||||
|
return "сервис не принял описание (пустое или длиннее 4000 символов)"
|
||||||
|
case "invalid_image":
|
||||||
|
return "сервис не принял фото (нужен png, jpg или webp)"
|
||||||
|
case "image_too_large":
|
||||||
|
return "фото больше 20 МБ"
|
||||||
|
case "tokens_exhausted":
|
||||||
|
return "сервис картинок перегружен - попробуйте позже"
|
||||||
|
case "generation_failed", "edit_failed":
|
||||||
|
return "сервис не справился с картинкой (кредиты вернутся)"
|
||||||
|
case "source_missing":
|
||||||
|
return "исходное фото потерялось на сервисе - загрузите его заново"
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "сервис картинок ответил ошибкой"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageClient = &http.Client{Timeout: 90 * time.Second}
|
||||||
|
|
||||||
|
// imageCall sends the request to the service with the key and reads its
|
||||||
|
// JSON; an error is words for the user.
|
||||||
|
func imageCall(ctx context.Context, req *http.Request, key string, v any) error {
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := imageClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("сервис картинок не отвечает: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
var e struct {
|
||||||
|
OK *bool `json:"ok"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &e) != nil {
|
||||||
|
return errors.New("сервис картинок ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
if (e.OK != nil && !*e.OK) || resp.StatusCode/100 != 2 {
|
||||||
|
return errors.New(imageError(e.ErrorCode, e.Error))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
imageRatios = map[string]bool{"1:1": true, "16:9": true, "9:16": true, "3:2": true, "2:3": true, "4:3": true, "3:4": true, "auto": true}
|
||||||
|
imageModels = map[string]bool{"fast": true, "artist": true, "0.2plus": true}
|
||||||
|
imageJobRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
imageExt = map[string]string{"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/panel/ai/image - {mode: generate|edit, prompt, model, ratio,
|
||||||
|
// image: {mime, data (base64)} | image_url}: puts the job, answers its id.
|
||||||
|
func (s *Server) handleAIImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||||
|
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||||
|
fail("ассистент выключен в профиле")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, key := s.imageConf()
|
||||||
|
if key == "" {
|
||||||
|
fail("не задан API-ключ сервиса картинок (Настройки › ИИ-ассистент › Генерация изображений)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Ratio string `json:"ratio"`
|
||||||
|
Enhance *bool `json:"enhance"` // the service improves the description; on unless false
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Image struct {
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"image"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in); err != nil {
|
||||||
|
fail("плохой запрос")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Prompt = strings.TrimSpace(in.Prompt)
|
||||||
|
if in.Prompt == "" {
|
||||||
|
fail("пустое описание картинки")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r := []rune(in.Prompt); len(r) > 4000 {
|
||||||
|
in.Prompt = string(r[:4000])
|
||||||
|
}
|
||||||
|
var job struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
if in.Mode != "edit" {
|
||||||
|
if !imageModels[in.Model] {
|
||||||
|
in.Model = "fast"
|
||||||
|
}
|
||||||
|
if !imageRatios[in.Ratio] {
|
||||||
|
in.Ratio = "1:1"
|
||||||
|
}
|
||||||
|
enhance := in.Enhance == nil || *in.Enhance
|
||||||
|
b, _ := json.Marshal(map[string]any{"prompt": in.Prompt, "model": in.Model, "ratio": in.Ratio, "enhance": enhance})
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/generate", bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mime, data, err := s.imageSource(ctx, base, in.Image.MIME, in.Image.Data, in.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
h := textproto.MIMEHeader{}
|
||||||
|
h.Set("Content-Disposition", `form-data; name="image"; filename="photo.`+imageExt[mime]+`"`)
|
||||||
|
h.Set("Content-Type", mime)
|
||||||
|
part, _ := mw.CreatePart(h)
|
||||||
|
_, _ = part.Write(data)
|
||||||
|
_ = mw.WriteField("prompt", in.Prompt)
|
||||||
|
_ = mw.Close()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/edit", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if job.JobID == "" {
|
||||||
|
fail("сервис картинок не дал номер задачи")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "job_id": job.JobID, "cost": job.Cost})
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageSource is the photo to edit: sent by the browser, or a picture made
|
||||||
|
// before, taken from the service's own address (and from no other).
|
||||||
|
func (s *Server) imageSource(ctx context.Context, base, mime, b64, picURL string) (string, []byte, error) {
|
||||||
|
if picURL != "" {
|
||||||
|
u, err := url.Parse(picURL)
|
||||||
|
bu, _ := url.Parse(base)
|
||||||
|
if err != nil || bu == nil || u.Host != bu.Host || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return "", nil, errors.New("прежнюю картинку можно править только с сервиса картинок")
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
resp, err := imageClient.Do(req)
|
||||||
|
if err != nil || resp.StatusCode/100 != 2 {
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
return "", nil, errors.New("прежняя картинка больше не открывается")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, imageMaxBytes+1))
|
||||||
|
mime = strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
|
||||||
|
if imageExt[mime] == "" {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
return mime, data, nil
|
||||||
|
}
|
||||||
|
mime = strings.ToLower(mime)
|
||||||
|
if imageExt[mime] == "" {
|
||||||
|
return "", nil, errors.New("править можно png, jpg и webp")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return "", nil, errors.New("нет фото для правки: прикрепите его к сообщению")
|
||||||
|
}
|
||||||
|
if len(data) > imageMaxBytes {
|
||||||
|
return "", nil, errors.New("фото больше 20 МБ")
|
||||||
|
}
|
||||||
|
return mime, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/ai/image/{job}?wait=1..45 - the job's state, waiting up to
|
||||||
|
// wait seconds for it to end.
|
||||||
|
func (s *Server) handleAIImageJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||||
|
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||||
|
fail("ассистент выключен в профиле")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, key := s.imageConf()
|
||||||
|
id := r.PathValue("job")
|
||||||
|
if key == "" || !imageJobRe.MatchString(id) {
|
||||||
|
fail("нет такой задачи")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wait, _ := strconv.Atoi(r.URL.Query().Get("wait"))
|
||||||
|
if wait < 0 {
|
||||||
|
wait = 0
|
||||||
|
}
|
||||||
|
if wait > 45 {
|
||||||
|
wait = 45
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, base+"/api/vertex/v1/jobs/"+id+"?wait="+strconv.Itoa(wait), nil)
|
||||||
|
var st struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := imageCall(r.Context(), req, key, &st); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]any{"ok": true, "status": st.Status}
|
||||||
|
switch st.Status {
|
||||||
|
case "done":
|
||||||
|
out["photo_url"], out["cost"], out["balance"] = st.PhotoURL, st.Cost, st.Balance
|
||||||
|
case "failed":
|
||||||
|
out["error"] = imageError(st.ErrorCode, st.Error)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeVertex is the image service: it records what it was asked and answers
|
||||||
|
// like the real one (a job to poll, a picture when done).
|
||||||
|
type fakeVertex struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
auth []string
|
||||||
|
gen map[string]any
|
||||||
|
edit struct {
|
||||||
|
prompt string
|
||||||
|
image []byte
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
polls int
|
||||||
|
failAs string // an error_code the job ends with
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeVertex(t *testing.T) *fakeVertex {
|
||||||
|
f := &fakeVertex{}
|
||||||
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.auth = append(f.auth, r.Header.Get("Authorization"))
|
||||||
|
// The pictures are open by their address, as at the real service.
|
||||||
|
if r.URL.Path == "/uploads/userphoto/ai/t.png" {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte("\x89PNG-original"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Header.Get("Authorization") != "Bearer vx_good" {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, `{"ok":false,"error_code":"unauthorized","error":"Неверный ключ"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/vertex/v1/generate":
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&f.gen)
|
||||||
|
w.WriteHeader(202)
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"job_gen1","status":"queued","cost":5}`)
|
||||||
|
case r.URL.Path == "/api/vertex/v1/edit":
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
fmt.Fprint(w, `{"ok":false,"error_code":"invalid_image","error":"нет фото"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.edit.prompt = r.FormValue("prompt")
|
||||||
|
file, hdr, err := r.FormFile("image")
|
||||||
|
if err == nil {
|
||||||
|
f.edit.image, _ = io.ReadAll(file)
|
||||||
|
f.edit.name = hdr.Filename
|
||||||
|
}
|
||||||
|
w.WriteHeader(202)
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"job_edit1","status":"queued","cost":2}`)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/vertex/v1/jobs/"):
|
||||||
|
f.polls++
|
||||||
|
if f.failAs != "" {
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"failed","error_code":%q,"error":"сбой"}`, f.failAs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.polls < 2 {
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"x","status":"running"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"done","photo_url":"%s/uploads/userphoto/ai/t.png","cost":5,"balance":42}`, f.srv.URL)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.srv.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// setImage points the panel at the fake service with a key.
|
||||||
|
func setImage(t *testing.T, s *Server, f *fakeVertex, key string) {
|
||||||
|
t.Helper()
|
||||||
|
_ = s.db.SetConfig(cfgImageURL, f.srv.URL)
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiCall(t *testing.T, s *Server, c *http.Cookie, method, path, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.AddCookie(c)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A picture from nothing: the prompt, model and ratio go to the service with
|
||||||
|
// the key; the job's progress and its picture come back, the key never.
|
||||||
|
func TestImageGenerate(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"cat in space","model":"artist","ratio":"3:4"}`)
|
||||||
|
if !strings.Contains(got, `"ok":true`) || !strings.Contains(got, `"job_id":"job_gen1"`) || strings.Contains(got, "vx_good") {
|
||||||
|
t.Fatalf("create: %s", got)
|
||||||
|
}
|
||||||
|
if f.gen["prompt"] != "cat in space" || f.gen["model"] != "artist" || f.gen["ratio"] != "3:4" || f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("what the service got: %v", f.gen)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", ""); !strings.Contains(got, `"status":"running"`) {
|
||||||
|
t.Fatalf("first poll: %s", got)
|
||||||
|
}
|
||||||
|
got = apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", "")
|
||||||
|
if !strings.Contains(got, `"status":"done"`) || !strings.Contains(got, "/uploads/userphoto/ai/t.png") || !strings.Contains(got, `"balance":42`) || strings.Contains(got, "vx_good") {
|
||||||
|
t.Fatalf("done: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown models and ratios are not sent on: the defaults go instead.
|
||||||
|
func TestImageGenerateDefaults(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","model":"gpt","ratio":"7:1"}`)
|
||||||
|
if f.gen["model"] != "fast" || f.gen["ratio"] != "1:1" {
|
||||||
|
t.Fatalf("defaults: %v", f.gen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing sends the user's photo as a file with the instruction.
|
||||||
|
func TestImageEditFromAttachedPhoto(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
photo := []byte("\xff\xd8\xffJPEGDATA")
|
||||||
|
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "сделай закат",
|
||||||
|
"image": map[string]string{"mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(photo)}})
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b))
|
||||||
|
if !strings.Contains(got, `"job_id":"job_edit1"`) {
|
||||||
|
t.Fatalf("edit: %s", got)
|
||||||
|
}
|
||||||
|
if f.edit.prompt != "сделай закат" || !bytes.Equal(f.edit.image, photo) || !strings.HasSuffix(f.edit.name, ".jpg") {
|
||||||
|
t.Fatalf("what the service got: %q %q %q", f.edit.prompt, f.edit.image, f.edit.name)
|
||||||
|
}
|
||||||
|
// A format the service does not take is told so, not sent.
|
||||||
|
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image": map[string]string{"mime": "image/gif", "data": "R0lG"}})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "png") {
|
||||||
|
t.Fatalf("gif: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A picture made before can be edited again: fetched from the service's own
|
||||||
|
// address only - never from another.
|
||||||
|
func TestImageEditFromEarlierPicture(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "темнее", "image_url": f.srv.URL + "/uploads/userphoto/ai/t.png"})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":true`) {
|
||||||
|
t.Fatalf("by url: %s", got)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(f.edit.image, []byte("\x89PNG-original")) || !strings.HasSuffix(f.edit.name, ".png") {
|
||||||
|
t.Fatalf("the picture sent: %q %q", f.edit.image, f.edit.name)
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image_url": "http://127.0.0.1:9/secret.png"})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("a foreign address was fetched: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What goes wrong is told in words: no key, a wrong key, the service's own
|
||||||
|
// codes, a job that failed.
|
||||||
|
func TestImageErrors(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "ключ") {
|
||||||
|
t.Fatalf("no key: %s", got)
|
||||||
|
}
|
||||||
|
setImage(t, s, f, "vx_bad")
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "ключ") {
|
||||||
|
t.Fatalf("wrong key: %s", got)
|
||||||
|
}
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
f.failAs = "insufficient_credits"
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1", ""); !strings.Contains(got, `"status":"failed"`) || !strings.Contains(got, "кредит") {
|
||||||
|
t.Fatalf("failed job: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/..%2Fme", ""); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("a job id out of shape: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":""}`); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("empty prompt: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The assistant off in the profile means no pictures either.
|
||||||
|
func TestImageNeedsAssistantOn(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("off: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is saved from Настройки, shown back to the admin, and the chat
|
||||||
|
// learns only that pictures are on.
|
||||||
|
func TestImageKeySetting(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
postAIModels(t, s, c, [][7]string{{"", "M", "gradio", "", "", "", ""}})
|
||||||
|
f := url.Values{"csrf": {"tok"}, "m_type": {"gradio"}, "ai_image_key": {"vx_secret"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(c)
|
||||||
|
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||||
|
s.Handler().ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
if _, k := s.imageConf(); k != "vx_secret" {
|
||||||
|
t.Fatalf("key not saved: %q", k)
|
||||||
|
}
|
||||||
|
if body := get(t, s, c, "/settings?frag=1").Body.String(); !strings.Contains(body, `value="vx_secret"`) {
|
||||||
|
t.Fatal("the settings page does not show the key")
|
||||||
|
}
|
||||||
|
state := get(t, s, c, "/api/panel/ai").Body.String()
|
||||||
|
if strings.Contains(state, "vx_secret") || !strings.Contains(state, `"image":true`) {
|
||||||
|
t.Fatalf("state: %s", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a key the model is told it can draw; without, it is not.
|
||||||
|
func TestImagePromptOnlyWithKey(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
if strings.Contains(s.imagePrompt(), "```image") {
|
||||||
|
t.Fatal("told about pictures with no key")
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
p := s.imagePrompt()
|
||||||
|
for _, want := range []string{"```image\n", "```image edit", "fast", "artist", "0.2plus", "16:9"} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Fatalf("the prompt lacks %q:\n%s", want, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chat gives the model the image instructions when pictures are on.
|
||||||
|
func TestAIChatMentionsImageTool(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
var system string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||||
|
system, _ = b["messages"].([]any)[0].(map[string]any)["content"].(string)
|
||||||
|
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
|
||||||
|
ask := func() {
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/chat", `{"message":"нарисуй кота","context":"x"}`)
|
||||||
|
}
|
||||||
|
ask()
|
||||||
|
if strings.Contains(system, "```image") {
|
||||||
|
t.Fatal("image tool offered with no key")
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
ask()
|
||||||
|
if !strings.Contains(system, "```image") {
|
||||||
|
t.Fatalf("image tool not offered with a key:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The description is improved by the service unless the model asks for it
|
||||||
|
// as it is.
|
||||||
|
func TestImageEnhanceSwitch(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`)
|
||||||
|
if f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("by default: %v", f.gen)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":false}`)
|
||||||
|
if f.gen["enhance"] != false {
|
||||||
|
t.Fatalf("switched off: %v", f.gen)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":true}`)
|
||||||
|
if f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("switched on: %v", f.gen)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
p := s.imagePrompt()
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
if !strings.Contains(s.imagePrompt(), "noenhance") {
|
||||||
|
t.Fatalf("the model is not told of the switch:\n%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
|
// 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
|
// 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
|
// 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 {
|
type viewPrefs struct {
|
||||||
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
||||||
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
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
|
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
||||||
Motion string `json:"motion,omitempty"` // on (default), off
|
Motion string `json:"motion,omitempty"` // on (default), off
|
||||||
OpenHere string `json:"openHere,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) }
|
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
|
||||||
@@ -38,6 +45,12 @@ func (p viewPrefs) clean() viewPrefs {
|
|||||||
if p.OpenHere != "off" {
|
if p.OpenHere != "off" {
|
||||||
p.OpenHere = ""
|
p.OpenHere = ""
|
||||||
}
|
}
|
||||||
|
if p.AIModelPick != "always" {
|
||||||
|
p.AIModelPick = ""
|
||||||
|
}
|
||||||
|
if len(p.AIModel) > 32 || strings.Trim(p.AIModel, "0123456789abcdefghijklmnopqrstuvwxyz") != "" {
|
||||||
|
p.AIModel = ""
|
||||||
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
|
|||||||
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
|
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
|
||||||
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
|
||||||
a := s.currentAdmin(r)
|
a := s.currentAdmin(r)
|
||||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
if !s.aiOn(a.ID) {
|
||||||
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
|
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
|
||||||
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
|
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
|
||||||
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
|
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
|
||||||
|
mux.Handle("POST /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
|
||||||
|
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
|
||||||
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
||||||
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
|
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
|
||||||
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
|
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
|
||||||
|
|||||||
@@ -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="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="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="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>' +
|
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
|
||||||
' </div></details>' +
|
' </div></details>' +
|
||||||
' <div class="acct-keysec">' +
|
' <div class="acct-keysec">' +
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 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 esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||||
const fab = document.createElement('button');
|
const fab = document.createElement('button');
|
||||||
fab.type = '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>' +
|
'<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="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
'<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>' +
|
'<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>' +
|
'</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>' +
|
'<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">' +
|
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
const isOpen = () => box.classList.contains('on');
|
const isOpen = () => box.classList.contains('on');
|
||||||
function show() {
|
function show() {
|
||||||
loadTalk();
|
loadTalk();
|
||||||
|
loadState();
|
||||||
placeBox();
|
placeBox();
|
||||||
box.classList.add('on');
|
box.classList.add('on');
|
||||||
fab.classList.add('away');
|
fab.classList.add('away');
|
||||||
@@ -102,7 +107,18 @@
|
|||||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
||||||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||||||
const desk = !matchMedia('(pointer: coarse)').matches;
|
const desk = !matchMedia('(pointer: coarse)').matches;
|
||||||
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
|
// The models to choose from come with the state, again on every opening
|
||||||
|
// (an admin may have changed them).
|
||||||
|
let models = [];
|
||||||
|
// Pictures are on when the admin set the image service's key: the model then
|
||||||
|
// asks for them in its answers (```image blocks) - no switch of the user's.
|
||||||
|
let imageOn = false;
|
||||||
|
// The photos of the user's last message that had any, and the last picture
|
||||||
|
// made here: what an edit works on.
|
||||||
|
let sentImages = [], lastPic = '';
|
||||||
|
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
|
||||||
|
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
|
||||||
|
loadState();
|
||||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||||
|
|
||||||
@@ -204,7 +220,6 @@
|
|||||||
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
|
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
|
||||||
row.querySelector('.ai-chatdel').onclick = async ev => {
|
row.querySelector('.ai-chatdel').onclick = async ev => {
|
||||||
ev.stopPropagation();
|
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'});
|
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
|
||||||
if (c.id === chatId) openChat(0);
|
if (c.id === chatId) openChat(0);
|
||||||
loadChats();
|
loadChats();
|
||||||
@@ -228,6 +243,7 @@
|
|||||||
setChat(id);
|
setChat(id);
|
||||||
drawChats();
|
drawChats();
|
||||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||||
|
sentImages = []; lastPic = '';
|
||||||
list.scrollTop = 0;
|
list.scrollTop = 0;
|
||||||
showJump(false);
|
showJump(false);
|
||||||
hideFileError();
|
hideFileError();
|
||||||
@@ -238,7 +254,7 @@
|
|||||||
if (chatId !== id) return;
|
if (chatId !== id) return;
|
||||||
h.forEach(m => {
|
h.forEach(m => {
|
||||||
if (m.role === 'result') showResult(m.content);
|
if (m.role === 'result') showResult(m.content);
|
||||||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; if (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
|
||||||
});
|
});
|
||||||
if (!h.length) q('.ai-hello').hidden = false;
|
if (!h.length) q('.ai-hello').hidden = false;
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
@@ -358,6 +374,7 @@
|
|||||||
const p = r.data;
|
const p = r.data;
|
||||||
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
|
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
|
||||||
}
|
}
|
||||||
|
if (a.kind === 'image') return makeImage(a, signal);
|
||||||
// A web search: through the server, only with «Поиск» on.
|
// A web search: through the server, only with «Поиск» on.
|
||||||
if (a.kind === 'search') {
|
if (a.kind === 'search') {
|
||||||
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
||||||
@@ -412,13 +429,16 @@
|
|||||||
// ---------- messages ----------
|
// ---------- messages ----------
|
||||||
// stripThink: the answer without its thinking - what is acted on and kept.
|
// stripThink: the answer without its thinking - what is acted on and kept.
|
||||||
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
|
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
|
||||||
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
|
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'};
|
||||||
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
|
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
|
||||||
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
|
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
|
||||||
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
||||||
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
|
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
|
||||||
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
|
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
|
||||||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
|
image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
|
||||||
|
// What the button of a card that has been done says (else «Готово»).
|
||||||
|
const DONE = {image: 'Создано'};
|
||||||
|
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
|
||||||
// render: text with ``` blocks; action blocks get their button.
|
// render: text with ``` blocks; action blocks get their button.
|
||||||
// render draws an answer: its thinking, folded, above; the text with
|
// render draws an answer: its thinking, folded, above; the text with
|
||||||
// Markdown; ``` blocks apart, action blocks with their button. The
|
// Markdown; ``` blocks apart, action blocks with their button. The
|
||||||
@@ -458,16 +478,67 @@
|
|||||||
// An action: a card with what and where on its title line, a small
|
// An action: a card with what and where on its title line, a small
|
||||||
// button on the right of it, the command or text below.
|
// button on the right of it, the command or text below.
|
||||||
if (act) {
|
if (act) {
|
||||||
const where = head.slice(kind.length).trim();
|
// A picture's title says what it is (a new one or an edit); its first line
|
||||||
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
|
// (format, quality, file) is not shown - the words under it are.
|
||||||
|
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
|
||||||
|
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
|
||||||
|
// The text of a picture or a search folds: a line, or open - up to
|
||||||
|
// a few lines, then it scrolls. What the user left open stays so.
|
||||||
|
const fold = (kind === 'image' || kind === 'search') && code;
|
||||||
|
const open = fold && el._open && el._open.has(idx);
|
||||||
|
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
|
||||||
|
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
|
||||||
|
ICONS[kind] + '<b>' + title + '</b>' +
|
||||||
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
|
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
|
||||||
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
|
'<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + LABEL[kind] + '</button></div>' +
|
||||||
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
|
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
|
||||||
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
|
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
|
||||||
});
|
});
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
el.dataset.text = text;
|
el.dataset.text = text;
|
||||||
|
// A text that fits one line has nothing to unfold: no arrow for it.
|
||||||
|
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
|
||||||
|
const p = c.querySelector('pre');
|
||||||
|
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
// animateHeight: change() flips the card between folded and open; the
|
||||||
|
// text's box grows or shrinks to its new height instead of jumping. The
|
||||||
|
// text keeps its wrapped lines while it shrinks.
|
||||||
|
function animateHeight(pre, change) {
|
||||||
|
const h0 = pre.getBoundingClientRect().height;
|
||||||
|
change();
|
||||||
|
const still = document.body.classList.contains('no-motion') || matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const h1 = pre.getBoundingClientRect().height;
|
||||||
|
if (still || Math.abs(h1 - h0) < 1) return;
|
||||||
|
const st = pre.style;
|
||||||
|
st.boxSizing = 'border-box'; st.overflow = 'hidden'; st.whiteSpace = 'pre-wrap'; st.textOverflow = 'clip';
|
||||||
|
st.height = h0 + 'px';
|
||||||
|
pre.getBoundingClientRect(); // the start is drawn before the end is set
|
||||||
|
st.transition = 'height .24s cubic-bezier(.3,.7,.3,1)';
|
||||||
|
st.height = h1 + 'px';
|
||||||
|
let t = 0;
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(t); pre.removeEventListener('transitionend', done);
|
||||||
|
st.height = st.transition = st.overflow = st.whiteSpace = st.textOverflow = st.boxSizing = '';
|
||||||
|
};
|
||||||
|
pre.addEventListener('transitionend', done);
|
||||||
|
t = setTimeout(done, 450);
|
||||||
|
}
|
||||||
|
const FOLD_CHEV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
|
||||||
|
list.addEventListener('click', ev => {
|
||||||
|
const t = ev.target.closest('.foldable .ai-fold, .foldable pre');
|
||||||
|
if (!t) return;
|
||||||
|
const card = t.closest('.foldable');
|
||||||
|
if (card.classList.contains('short') && !card.classList.contains('open')) return;
|
||||||
|
const msg = card.closest('.ai-msg'), pre = card.querySelector('pre');
|
||||||
|
let on = false;
|
||||||
|
animateHeight(pre, () => { on = card.classList.toggle('open'); });
|
||||||
|
const i = Number(card.dataset.n);
|
||||||
|
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
|
||||||
|
const b = card.querySelector('.ai-fold');
|
||||||
|
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
|
||||||
|
});
|
||||||
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
|
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
|
||||||
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
|
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
|
||||||
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
|
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
|
||||||
@@ -505,7 +576,14 @@
|
|||||||
fileErrTimer = setTimeout(hideFileError, 5000);
|
fileErrTimer = setTimeout(hideFileError, 5000);
|
||||||
}
|
}
|
||||||
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
|
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
|
||||||
|
// The file itself stays with its attachment (not sent anywhere): it is what
|
||||||
|
// a click on its name opens or saves.
|
||||||
async function makeAttachments(file, name = file.name) {
|
async function makeAttachments(file, name = file.name) {
|
||||||
|
const made = await makeAttachmentsOf(file, name);
|
||||||
|
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
async function makeAttachmentsOf(file, name = file.name) {
|
||||||
const ext = fileExt(name), type = (file.type || '').toLowerCase();
|
const ext = fileExt(name), type = (file.type || '').toLowerCase();
|
||||||
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
|
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
|
||||||
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
|
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
|
||||||
@@ -541,7 +619,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const FILE_KIND = file => {
|
const FILE_KIND = file => {
|
||||||
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
|
// Of an earlier talk only the name is kept: the kind comes from it.
|
||||||
|
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').toLowerCase();
|
||||||
if (mime.startsWith('image/')) return ['image', 'Изображение'];
|
if (mime.startsWith('image/')) return ['image', 'Изображение'];
|
||||||
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
|
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
|
||||||
if (mime.startsWith('video/')) return ['video', 'Видео'];
|
if (mime.startsWith('video/')) return ['video', 'Видео'];
|
||||||
@@ -557,6 +636,30 @@
|
|||||||
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
|
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
|
||||||
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
|
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
|
||||||
};
|
};
|
||||||
|
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
|
||||||
|
// is shown as plain text, so it cannot run anything; the rest is saved.
|
||||||
|
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
|
||||||
|
function openAttached(file) {
|
||||||
|
let blob = file.blob || null, show = false;
|
||||||
|
// The type of the file itself - not of its text (a Word file's attachment
|
||||||
|
// is its text, plain, but the file is not).
|
||||||
|
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
|
||||||
|
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
|
||||||
|
if (!blob && file.data) {
|
||||||
|
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
blob = new Blob([bytes], {type});
|
||||||
|
}
|
||||||
|
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
|
||||||
|
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
|
||||||
|
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 120000);
|
||||||
|
}
|
||||||
function renderFileCards(host, files) {
|
function renderFileCards(host, files) {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
const group = document.createElement('div'); group.className = 'ai-history-files';
|
const group = document.createElement('div'); group.className = 'ai-history-files';
|
||||||
@@ -566,7 +669,13 @@
|
|||||||
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
|
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
|
||||||
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
|
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
|
||||||
const info = document.createElement('span'); info.className = 'ai-history-file-info';
|
const info = document.createElement('span'); info.className = 'ai-history-file-info';
|
||||||
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
|
// The name opens the file (or saves it) - when it is at hand: this
|
||||||
|
// page's own attachments; of an earlier talk only a text file's text.
|
||||||
|
const has = !!(file.blob || file.data || file.text);
|
||||||
|
const name = document.createElement(has ? 'button' : 'span');
|
||||||
|
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
|
||||||
|
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
|
||||||
|
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
|
||||||
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
|
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
|
||||||
const ext = fileExt(file.name).toUpperCase();
|
const ext = fileExt(file.name).toUpperCase();
|
||||||
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
|
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
|
||||||
@@ -614,6 +723,51 @@
|
|||||||
const el = add('result', '');
|
const el = add('result', '');
|
||||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||||
el.querySelector('pre').textContent = text;
|
el.querySelector('pre').textContent = text;
|
||||||
|
// A picture made: shown here, from its marker (kept with the talk, so
|
||||||
|
// it comes back with it) - and what the next edit works on.
|
||||||
|
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
|
||||||
|
if (!m) return;
|
||||||
|
lastPic = m[1];
|
||||||
|
const follow = nearBottom();
|
||||||
|
const link = document.createElement('a'), img = document.createElement('img');
|
||||||
|
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
|
||||||
|
img.alt = 'Картинка'; img.src = m[1];
|
||||||
|
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
|
||||||
|
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
|
||||||
|
link.appendChild(img);
|
||||||
|
el.classList.add('pic');
|
||||||
|
el.insertBefore(link, el.firstChild);
|
||||||
|
}
|
||||||
|
// makeImage: a picture asked for in an answer - put at the image service
|
||||||
|
// through the server, asked about until it is done. What comes back is
|
||||||
|
// what the model is told: that it is made and shown (or why not).
|
||||||
|
async function makeImage(a, signal) {
|
||||||
|
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
|
||||||
|
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
|
||||||
|
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
|
||||||
|
if (a.edit) {
|
||||||
|
const want = (a.file || '').toLowerCase();
|
||||||
|
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
|
||||||
|
if (pick) body.image = {mime: pick.mime, data: pick.data};
|
||||||
|
else if (lastPic) body.image_url = lastPic;
|
||||||
|
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
|
||||||
|
}
|
||||||
|
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||||
|
if (!r.ok) return 'Ошибка картинки: ' + r.error;
|
||||||
|
let lost = 0;
|
||||||
|
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
|
||||||
|
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
|
||||||
|
if (!s.ok) {
|
||||||
|
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
|
||||||
|
return 'Ошибка картинки: ' + s.error;
|
||||||
|
}
|
||||||
|
if (s.status === 'done') {
|
||||||
|
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
|
||||||
|
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
|
||||||
|
}
|
||||||
|
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
|
||||||
|
}
|
||||||
|
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
|
||||||
}
|
}
|
||||||
list.addEventListener('click', async ev => {
|
list.addEventListener('click', async ev => {
|
||||||
const b = ev.target.closest('.ai-act');
|
const b = ev.target.closest('.ai-act');
|
||||||
@@ -623,7 +777,7 @@
|
|||||||
b.disabled = true;
|
b.disabled = true;
|
||||||
b.textContent = '…';
|
b.textContent = '…';
|
||||||
const res = await perform(a);
|
const res = await perform(a);
|
||||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
|
||||||
showResult(res);
|
showResult(res);
|
||||||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
|
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
|
||||||
});
|
});
|
||||||
@@ -647,14 +801,13 @@
|
|||||||
// While the model starts: a typing mark, gone with the first words.
|
// 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>';
|
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
|
||||||
ac = new AbortController();
|
ac = new AbortController();
|
||||||
send.innerHTML = STOP; send.title = 'Остановить';
|
|
||||||
sendState();
|
sendState();
|
||||||
let answer = '';
|
let answer = '';
|
||||||
try {
|
try {
|
||||||
await loadMachines();
|
await loadMachines();
|
||||||
const r = await fetch('/api/panel/ai/chat', {
|
const r = await fetch('/api/panel/ai/chat', {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
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.
|
// A new chat gets its id (and its name) from the answer.
|
||||||
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
||||||
@@ -690,6 +843,61 @@
|
|||||||
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
|
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
|
||||||
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
|
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
|
||||||
const searchOn = () => pill('search').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.
|
// The modes are kept in this browser.
|
||||||
box.querySelectorAll('.ai-pill').forEach(b => {
|
box.querySelectorAll('.ai-pill').forEach(b => {
|
||||||
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
|
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
|
||||||
@@ -703,26 +911,28 @@
|
|||||||
// first action of each answer done (asking first for commands and writes)
|
// first action of each answer done (asking first for commands and writes)
|
||||||
// and its result sent back - until an answer asks for nothing.
|
// and its result sent back - until an answer asks for nothing.
|
||||||
async function talkTurn(text, files) {
|
async function talkTurn(text, files) {
|
||||||
|
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
|
||||||
|
if (imgs.length) sentImages = imgs;
|
||||||
let turn = await ask(text, null, files);
|
let turn = await ask(text, null, files);
|
||||||
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
|
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
|
||||||
const a = WSUtil.parseActions(turn.answer)[0];
|
const a = WSUtil.parseActions(turn.answer)[0];
|
||||||
if (!a) break;
|
if (!a) break;
|
||||||
// Without «Агент» only searches go on by themselves, with «Поиск» on.
|
// Without «Агент» only searches go on by themselves, with «Поиск» on -
|
||||||
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
|
// and pictures, whenever they are on: the model asks for them itself.
|
||||||
|
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) break;
|
||||||
const b = turn.el.querySelector('.ai-act');
|
const b = turn.el.querySelector('.ai-act');
|
||||||
if (needsYes(a) && !await confirmAction(a)) {
|
if (needsYes(a) && !await confirmAction(a)) {
|
||||||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||||||
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (b) { b.disabled = true; b.textContent = '…'; }
|
if (b) { b.disabled = true; b.textContent = a.kind === 'image' ? 'Рисую…' : '…'; }
|
||||||
const res = await perform(a, ac.signal);
|
const res = await perform(a, ac.signal);
|
||||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
|
||||||
if (ac.signal.aborted) { showResult(res); break; }
|
if (ac.signal.aborted) { showResult(res); break; }
|
||||||
turn = await ask(null, res);
|
turn = await ask(null, res);
|
||||||
}
|
}
|
||||||
ac = null;
|
ac = null;
|
||||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
|
||||||
sendState();
|
sendState();
|
||||||
}
|
}
|
||||||
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
|
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
|
||||||
@@ -789,10 +999,23 @@
|
|||||||
dropFiles: addPaneFiles
|
dropFiles: addPaneFiles
|
||||||
};
|
};
|
||||||
// Send shows only when there is something to send, or an answer to stop.
|
// 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() {
|
function sendState() {
|
||||||
const on = !!ac || !!input.value.trim() || attachments.length > 0;
|
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('show', on);
|
||||||
send.classList.toggle('stop', !!ac);
|
|
||||||
send.tabIndex = on ? 0 : -1;
|
send.tabIndex = on ? 0 : -1;
|
||||||
}
|
}
|
||||||
input.addEventListener('input', sendState);
|
input.addEventListener('input', sendState);
|
||||||
@@ -812,8 +1035,8 @@
|
|||||||
// its width and wrap the last word a character too early.
|
// its width and wrap the last word a character too early.
|
||||||
function grow() {
|
function grow() {
|
||||||
input.style.overflowY = 'hidden';
|
input.style.overflowY = 'hidden';
|
||||||
input.style.height = '40px';
|
input.style.height = '32px';
|
||||||
const h = input.scrollHeight + 2;
|
const h = input.scrollHeight;
|
||||||
input.style.height = Math.min(140, h) + 'px';
|
input.style.height = Math.min(140, h) + 'px';
|
||||||
input.style.overflowY = h > 140 ? 'auto' : 'hidden';
|
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); }
|
display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); }
|
||||||
.ai-fab[hidden] { display:none; }
|
.ai-fab[hidden] { display:none; }
|
||||||
body.touch.term-active .ai-fab { bottom:104px; }
|
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;
|
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;
|
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);
|
opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96);
|
||||||
@@ -885,6 +885,10 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
|||||||
.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
|
.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
|
||||||
.ai-history-file-info { display:grid; gap:2px; min-width:0; }
|
.ai-history-file-info { display:grid; gap:2px; min-width:0; }
|
||||||
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
|
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
|
||||||
|
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
|
||||||
|
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
|
||||||
|
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
|
||||||
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
|
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
|
||||||
|
|
||||||
/* The model starting: three dots rising in turn. */
|
/* The model starting: three dots rising in turn. */
|
||||||
@@ -896,30 +900,27 @@ body.touch.term-active .ai-fab { bottom:104px; }
|
|||||||
.ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; }
|
.ai-code 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-code .ai-run { margin-top:6px; }
|
||||||
.ai-form-in { display:flex; padding:8px 10px 10px; }
|
.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); }
|
.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:
|
/* 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-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-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; }
|
.ai-attach:hover { color:var(--text); background:#1b222c; }
|
||||||
/* Send, as in Telegram: no plate, a blue plane that comes in when there is
|
/* 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
|
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
|
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. */
|
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;
|
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%;
|
color:var(--accent); background:transparent; border:0; border-radius:50%;
|
||||||
opacity:0; transform:scale(.4); pointer-events:none;
|
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; }
|
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:hover { color:#7ab6ff; background:#1b222c; }
|
||||||
.ai-send:active { transform:scale(.9); }
|
.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: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-send .ai-plane { display:block; flex:none; transform:translateX(1px); }
|
||||||
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
|
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
|
||||||
@@ -948,6 +949,7 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
|
|||||||
.uidlg .ui-text { overflow:hidden; }
|
.uidlg .ui-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-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[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 { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
|
||||||
.sshdlg .chk input { width:auto; }
|
.sshdlg .chk input { width:auto; }
|
||||||
.sshdlg .saved-row[hidden] { display:none; }
|
.sshdlg .saved-row[hidden] { display:none; }
|
||||||
@@ -956,11 +958,44 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
|
|||||||
.ai-agent input { width:auto; margin:0; }
|
.ai-agent input { width:auto; margin:0; }
|
||||||
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
|
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
|
||||||
.ai-msg.result summary { cursor:pointer; }
|
.ai-msg.result summary { cursor:pointer; }
|
||||||
|
/* A picture the model made: shown whole, the result folded small under it. */
|
||||||
|
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
|
||||||
|
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
|
||||||
|
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
|
||||||
|
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
|
||||||
|
.ai-msg.result.pic details { padding:0 4px; }
|
||||||
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
|
.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; }
|
.ai-cap { font-size:12px; margin-bottom:4px; }
|
||||||
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
|
/* 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-pills { display:flex; align-items:center; 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 .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; }
|
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: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); }
|
.ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); }
|
||||||
@@ -981,7 +1016,7 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
|
|||||||
body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
|
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
|
/* The field and the send button: one height on every phone, the button
|
||||||
square whatever the text size. */
|
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; }
|
.ai-form-in textarea { border-radius:10px; }
|
||||||
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
|
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
|
||||||
.ai-form .keybox { display:flex; gap:6px; align-items:center; }
|
.ai-form .keybox { display:flex; gap:6px; align-items:center; }
|
||||||
@@ -1067,7 +1102,7 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
|||||||
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
|
.ai-code-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-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 { display:inline-flex; align-items:center; gap:6px; min-width:0; color:var(--muted); }
|
||||||
.ai-kind svg { flex:none; color:var(--accent); }
|
.ai-kind svg { align-self:center; flex:none; color:var(--accent); }
|
||||||
.ai-kind b { font-weight:500; color:var(--text); flex:none; }
|
.ai-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-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;
|
.ai-act { margin-left:auto; flex:none; height:24px; padding:0 9px; min-height:0; line-height:24px; border-radius:6px; border:0; font-size:12px; font-weight:500;
|
||||||
@@ -1075,6 +1110,18 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
|||||||
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
|
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
|
||||||
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
|
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
|
||||||
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
|
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
|
||||||
|
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
|
||||||
|
lines and then it scrolls. */
|
||||||
|
.ai-code.foldable { --ai-fold-lines:6; }
|
||||||
|
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
|
||||||
|
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
|
||||||
|
.ai-code.foldable.short pre { cursor:default; }
|
||||||
|
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
|
||||||
|
.ai-fold:hover { color:var(--text); background:#1b222c; }
|
||||||
|
.ai-fold svg { transition:transform .2s ease; }
|
||||||
|
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
|
||||||
|
.ai-code.foldable.short .ai-fold { display:none; }
|
||||||
/* Аккаунт › Вид. */
|
/* Аккаунт › Вид. */
|
||||||
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
||||||
.acct-view summary { font-size:14px; color:var(--text); }
|
.acct-view summary { font-size:14px; color:var(--text); }
|
||||||
@@ -1099,6 +1146,21 @@ 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; }
|
.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. */
|
/* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */
|
||||||
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
|
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
|
||||||
|
/* Настройки: the models, a card each. */
|
||||||
|
.ai-models { display:grid; gap:10px; }
|
||||||
|
.ai-model, .ai-search { min-width:0; margin:0; border:1px solid var(--border); border-radius:10px; }
|
||||||
|
/* Folded to a line: its name, and its kind and model quieter. */
|
||||||
|
.ai-model > summary.ai-model-sum, .ai-search > summary.ai-model-sum { min-width:0; padding:11px 12px; border-radius:10px; }
|
||||||
|
.ai-model > summary.ai-model-sum:hover, .ai-search > summary.ai-model-sum:hover { background:#151a21; }
|
||||||
|
.ai-model-title { flex:none; color:var(--text); font-size:14px; font-weight:550; }
|
||||||
|
.ai-model-sub { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12.5px; }
|
||||||
|
.ai-model-sub:not(:empty)::before { content:'· '; }
|
||||||
|
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
|
||||||
|
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:2px 0 4px; }
|
||||||
|
.ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; }
|
||||||
|
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
|
||||||
|
.ai-model-del { flex:none; height:38px; }
|
||||||
|
.ai-models .ai-model:only-child .ai-model-del { display:none; }
|
||||||
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
|
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
|
||||||
.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { 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. */
|
/* A question about an action: where, the command in mono, a note. */
|
||||||
|
|||||||
@@ -23,6 +23,33 @@
|
|||||||
}, e => alert('Не скопировалось: ' + e.message));
|
}, e => alert('Не скопировалось: ' + e.message));
|
||||||
});
|
});
|
||||||
if (window.UsersTable) window.UsersTable.mount(root);
|
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();
|
||||||
|
});
|
||||||
|
// A folded card's line follows its fields: the name, the kind and model.
|
||||||
|
list.addEventListener('input', ev => {
|
||||||
|
const card = ev.target.closest('.ai-model');
|
||||||
|
if (!card) return;
|
||||||
|
const v = n => card.querySelector('[name=' + n + ']').value.trim();
|
||||||
|
card.querySelector('.ai-model-title').textContent = v('m_name') || 'Модель';
|
||||||
|
card.querySelector('.ai-model-sub').textContent = v('m_type') + (v('m_model') ? ' · ' + v('m_model') : '');
|
||||||
|
});
|
||||||
|
// A field to fix in a folded card: the card opens to show it.
|
||||||
|
list.closest('form').addEventListener('invalid', ev => {
|
||||||
|
const d = ev.target.closest('details');
|
||||||
|
if (d) d.open = true;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +102,7 @@
|
|||||||
App.register('forwards', {
|
App.register('forwards', {
|
||||||
mount(root) {
|
mount(root) {
|
||||||
var notes = {
|
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 на хосте. Самая низкая задержка.',
|
tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.',
|
||||||
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
|
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
|
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
|
||||||
function apply() {
|
function apply() {
|
||||||
const b = document.body;
|
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.dataset.tabicons = p.tabIcons || 'left';
|
||||||
b.classList.toggle('compact', !!p.compact);
|
b.classList.toggle('compact', !!p.compact);
|
||||||
b.classList.toggle('no-ftime', p.fileTime === 'hide');
|
b.classList.toggle('no-ftime', p.fileTime === 'hide');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
function make() {
|
function make() {
|
||||||
dlg = document.createElement('dialog');
|
dlg = document.createElement('dialog');
|
||||||
dlg.className = 'uidlg';
|
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>';
|
'<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);
|
document.body.appendChild(dlg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,25 @@
|
|||||||
|
|
||||||
// parseActions finds what an assistant's answer asks the panel to do:
|
// parseActions finds what an assistant's answer asks the panel to do:
|
||||||
// closed ``` blocks headed run [machine], read|ls [machine:]path or
|
// closed ``` blocks headed run [machine], read|ls [machine:]path or
|
||||||
// write [machine:]path (its body the file's text).
|
// write [machine:]path (its body the file's text); search, fetch, and image
|
||||||
|
// (a picture to make or - with edit - to change; the first line may give
|
||||||
|
// its ratio, quality and the photo's file name).
|
||||||
function parseActions(text) {
|
function parseActions(text) {
|
||||||
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
|
const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\n]*)\n([\s\S]*?)```/g;
|
||||||
for (let m; (m = re.exec(text));) {
|
for (let m; (m = re.exec(text));) {
|
||||||
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
|
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
|
||||||
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
|
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
|
||||||
|
if (kind === 'image') {
|
||||||
|
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
|
||||||
|
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
|
||||||
|
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
|
||||||
|
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
|
||||||
|
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
|
||||||
|
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
|
||||||
|
file = rest.trim();
|
||||||
|
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
|
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
|
||||||
const c = head.indexOf(':');
|
const c = head.indexOf(':');
|
||||||
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
|
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
|
||||||
|
|||||||
@@ -85,3 +85,20 @@ test('parseActions: fetch blocks', () => {
|
|||||||
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
|
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
|
||||||
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
|
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
|
||||||
|
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
|
||||||
|
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
|
||||||
|
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
|
||||||
|
});
|
||||||
@@ -10,22 +10,19 @@
|
|||||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||||
}
|
}
|
||||||
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
|
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
|
||||||
/* The ufw switch hangs under the button, centred on it, level with the note. */
|
/* Under the fields: the note, and the ufw switch at the right, under the
|
||||||
.frow .act { position:relative; }
|
button - on a line of its own when the room runs out, never over the note. */
|
||||||
.frow .act label.check {
|
.under { display:flex; align-items:flex-start; justify-content:space-between; gap:8px 16px; flex-wrap:wrap;
|
||||||
position:absolute; top:100%; left:50%; transform:translateX(-50%);
|
min-height:20px; margin:12px 0 0; font-size:13px; line-height:20px; }
|
||||||
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
|
.under #note { flex:1 1 240px; min-width:0; }
|
||||||
color:var(--text); font-size:14px; cursor:pointer;
|
.under label.check { flex:none; display:inline-flex; align-items:center; gap:8px; height:20px; margin:0 0 0 auto;
|
||||||
}
|
color:var(--text); font-size:14px; cursor:pointer; white-space:nowrap; }
|
||||||
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
|
|
||||||
/* Phones: two fields a line, the label and the button each a line of their own. */
|
/* Phones: two fields a line, the label and the button each a line of their own. */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
|
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
|
||||||
.frow .fld.grow { flex:1 1 100%; }
|
.frow .fld.grow { flex:1 1 100%; }
|
||||||
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
|
.frow .act { width:100%; }
|
||||||
.frow .act button { flex:1; }
|
.frow .act button { width:100%; }
|
||||||
.frow .act label.check { position:static; transform:none; margin:0; }
|
|
||||||
.under { margin-right:0; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -94,21 +91,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="act">
|
<div class="act">
|
||||||
<button type="submit">Создать</button>
|
<button type="submit">Создать</button>
|
||||||
{{if .UfwEnabled}}
|
|
||||||
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
|
||||||
<input type="checkbox" name="no_ufw" value="1"> Без ufw
|
|
||||||
</label>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="under"><span class="muted" id="note"></span></div>
|
<div class="under"><span class="muted" id="note"></span>
|
||||||
|
{{if .UfwEnabled}}
|
||||||
|
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
||||||
|
<input type="checkbox" name="no_ufw" value="1"> Без ufw
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Режимы</h2>
|
<h2>Режимы</h2>
|
||||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
<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>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||||
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -152,17 +152,46 @@
|
|||||||
<h2>ИИ-ассистент</h2>
|
<h2>ИИ-ассистент</h2>
|
||||||
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
|
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<label>Тип API<select name="ai_type">
|
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
|
||||||
<option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
|
<div class="ai-models">
|
||||||
<option value="openai"{{if eq .AI.Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
|
{{range .AIModels}} <details class="ai-model"><summary class="ai-model-sum"><span class="ai-model-title">{{if .Name}}{{.Name}}{{else}}Модель{{end}}</span><span class="ai-model-sub">{{.Type}}{{if .Model}} · {{.Model}}{{end}}</span></summary>
|
||||||
<option value="anthropic"{{if eq .AI.Type "anthropic"}} selected{{end}}>Anthropic</option>
|
<div class="ai-model-body">
|
||||||
</select></label>
|
<input type="hidden" name="m_id" value="{{.ID}}">
|
||||||
<label>Адрес<input type="url" name="ai_url" value="{{.AI.URL}}" placeholder="https://api.openai.com/v1"></label>
|
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
|
||||||
<label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label>
|
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
|
||||||
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
|
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||||
<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>
|
<label>Тип API<select name="m_type">
|
||||||
<h3 class="ai-sub">Поиск в интернете</h3>
|
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
|
||||||
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
|
<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="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>
|
||||||
|
</div></details>
|
||||||
|
{{end}} </div>
|
||||||
|
<template class="ai-model-new">
|
||||||
|
<details class="ai-model" open><summary class="ai-model-sum"><span class="ai-model-title">Новая модель</span><span class="ai-model-sub"></span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
|
<input type="hidden" name="m_id" value="">
|
||||||
|
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
|
||||||
|
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
|
||||||
|
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||||
|
<label>Тип API<select name="m_type">
|
||||||
|
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
|
||||||
|
<option value="openai">OpenAI-совместимый</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
|
||||||
|
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
|
||||||
|
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||||
|
</div></details>
|
||||||
|
</template>
|
||||||
|
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
|
||||||
|
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Поиск в интернете</span><span class="ai-model-sub">Запасной - DuckDuckGo</span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
|
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
|
||||||
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
|
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
|
||||||
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
|
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
|
||||||
@@ -172,6 +201,13 @@
|
|||||||
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
|
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
|
||||||
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
|
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
|
||||||
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||||
|
</div></details>
|
||||||
|
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
|
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
|
||||||
|
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||||
|
</div></details>
|
||||||
<div><button type="submit">Сохранить</button></div>
|
<div><button type="submit">Сохранить</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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