Compare commits
7
Commits
0.2.3
..
3f288c6fd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f288c6fd6 | ||
|
|
3d2f886f04 | ||
|
|
916a49958b | ||
|
|
dde85031aa | ||
|
|
dd1244a169 | ||
|
|
c72fcfebf3 | ||
|
|
054fc97da8 |
No files matched your search
@@ -53,6 +53,13 @@
|
|||||||
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: 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` рядом с
|
||||||
@@ -60,6 +67,13 @@
|
|||||||
не расшифровать.
|
не расшифровать.
|
||||||
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||||
|
- **Автообновление** («Настройки › Автообновление», обе части выключены, пока не включены):
|
||||||
|
*клиентов* - агенты раз в N минут/часов/суток сверяют свою версию с версией сервера и, если у сервера новее,
|
||||||
|
обновляются сами (как по кнопке «Обновить»; нужен root или sudo без пароля; агентам до 0.2.5 один раз обновиться
|
||||||
|
придётся вручную); *сервера* - сервер раз в N сверяет себя с последним релизом на GitLab, и когда вышел новый,
|
||||||
|
администратору при входе (после PIN) показывается окно «Обновить / Отказаться» - отказ запоминается для этой
|
||||||
|
версии. «Обновить» запускает `server-install.sh` отдельной службой systemd (нужны root и systemd); там же кнопки
|
||||||
|
«Проверить сейчас» и «Обновить сейчас».
|
||||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||||
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
||||||
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.2.3
|
0.2.5
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -42,6 +43,9 @@ func main() {
|
|||||||
log.Fatalf("init server: %v", err)
|
log.Fatalf("init server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server updates itself, if told to (Настройки › Автообновление).
|
||||||
|
go srv.RunAutoUpdate(context.Background())
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,95 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-update, the agent's part: the server tells (with every poll, on its
|
||||||
|
// config and on the WebSocket) its own version and how often to compare -
|
||||||
|
// in minutes, 0 for never. When the server's version is newer, the agent
|
||||||
|
// takes the new one from it, as the panel's «Обновить» does.
|
||||||
|
|
||||||
|
// autoUpdate is what the agent was told, and when it last looked.
|
||||||
|
type autoUpdate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
minutes int
|
||||||
|
serverVer string
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) applyUpdateInfo(minutes int, serverVersion string) {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
defer a.auto.mu.Unlock()
|
||||||
|
if minutes < 0 {
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
a.auto.minutes = minutes
|
||||||
|
if serverVersion != "" {
|
||||||
|
a.auto.serverVer = serverVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUpdateHeaders takes the settings from the headers of an answer; an
|
||||||
|
// answer without them (an older server) changes nothing.
|
||||||
|
func (a *Agent) applyUpdateHeaders(h http.Header) {
|
||||||
|
v := h.Get(proto.AutoUpdateHeader)
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
m = 0
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(m, h.Get(proto.ServerVersionHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) updateInfo() (int, string) {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
defer a.auto.mu.Unlock()
|
||||||
|
return a.auto.minutes, a.auto.serverVer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) autoUpdateOn() bool { m, _ := a.updateInfo(); return m > 0 }
|
||||||
|
|
||||||
|
// autoUpdateTick: one look at the clock. Out of the interval since the last
|
||||||
|
// look, it looks; the server's version newer than its own, it updates.
|
||||||
|
// Reports whether it did.
|
||||||
|
func (a *Agent) autoUpdateTick(now time.Time, update func() proto.TaskResult) bool {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
if a.auto.minutes <= 0 || (!a.auto.last.IsZero() && now.Sub(a.auto.last) < time.Duration(a.auto.minutes)*time.Minute) {
|
||||||
|
a.auto.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.auto.last = now
|
||||||
|
server := a.auto.serverVer
|
||||||
|
a.auto.mu.Unlock()
|
||||||
|
if !proto.VersionNewer(server, Version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Printf("auto-update: server has %s, this is %s - updating", server, Version)
|
||||||
|
if res := update(); !res.OK {
|
||||||
|
log.Printf("auto-update: %s", res.Error)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoUpdateLoop watches the clock until ctx ends.
|
||||||
|
func (a *Agent) autoUpdateLoop(ctx context.Context) {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
a.autoUpdateTick(now, a.doUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updates counts what an agent was made to update with.
|
||||||
|
type updates struct{ n int }
|
||||||
|
|
||||||
|
func (u *updates) do() proto.TaskResult { u.n++; return proto.TaskResult{OK: true} }
|
||||||
|
|
||||||
|
// The agent is told, by headers or by the WebSocket's message, how often to
|
||||||
|
// compare versions; nothing told, nothing done.
|
||||||
|
func TestAutoUpdateIsTold(t *testing.T) {
|
||||||
|
a := New(Config{})
|
||||||
|
if a.autoUpdateOn() {
|
||||||
|
t.Fatal("on with nothing told")
|
||||||
|
}
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set(proto.AutoUpdateHeader, "90")
|
||||||
|
h.Set(proto.ServerVersionHeader, "9.9.9")
|
||||||
|
a.applyUpdateHeaders(h)
|
||||||
|
if m, v := a.updateInfo(); m != 90 || v != "9.9.9" {
|
||||||
|
t.Fatalf("by headers: %d %q", m, v)
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(0, "9.9.9")
|
||||||
|
if a.autoUpdateOn() {
|
||||||
|
t.Fatal("still on after being told 0")
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(30, "9.9.9")
|
||||||
|
if m, _ := a.updateInfo(); m != 30 {
|
||||||
|
t.Fatalf("by the message: %d", m)
|
||||||
|
}
|
||||||
|
// Rubbish is not taken for a number.
|
||||||
|
h.Set(proto.AutoUpdateHeader, "soon")
|
||||||
|
a.applyUpdateHeaders(h)
|
||||||
|
if m, _ := a.updateInfo(); m != 0 {
|
||||||
|
t.Fatalf("rubbish: %d", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the interval is out the agent looks; it updates only to a newer
|
||||||
|
// version of the server's, and tries again the next time if it did not work.
|
||||||
|
func TestAutoUpdateTick(t *testing.T) {
|
||||||
|
a := New(Config{})
|
||||||
|
u := &updates{}
|
||||||
|
now := time.Now()
|
||||||
|
a.applyUpdateInfo(60, "9.9.9")
|
||||||
|
|
||||||
|
if !a.autoUpdateTick(now, u.do) || u.n != 1 {
|
||||||
|
t.Fatalf("a newer server: %d updates", u.n)
|
||||||
|
}
|
||||||
|
if a.autoUpdateTick(now.Add(30*time.Minute), u.do) || u.n != 1 {
|
||||||
|
t.Fatalf("before the interval is out: %d updates", u.n)
|
||||||
|
}
|
||||||
|
if !a.autoUpdateTick(now.Add(61*time.Minute), u.do) || u.n != 2 {
|
||||||
|
t.Fatalf("at the next time: %d updates", u.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same, an older one, a build that is not a version: nothing.
|
||||||
|
for _, v := range []string{Version, "0.0.1", "dev", ""} {
|
||||||
|
b := New(Config{})
|
||||||
|
b.applyUpdateInfo(60, v)
|
||||||
|
if b.autoUpdateTick(now, u.do) {
|
||||||
|
t.Fatalf("updated to %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Off: nothing, whatever the server has.
|
||||||
|
c := New(Config{})
|
||||||
|
c.applyUpdateInfo(0, "9.9.9")
|
||||||
|
if c.autoUpdateTick(now, u.do) || u.n != 2 {
|
||||||
|
t.Fatalf("off, yet updated: %d", u.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ type Agent struct {
|
|||||||
tunMu sync.Mutex
|
tunMu sync.Mutex
|
||||||
tunAddr string
|
tunAddr string
|
||||||
tunCancel context.CancelFunc
|
tunCancel context.CancelFunc
|
||||||
|
|
||||||
|
auto autoUpdate // what the server told of auto-update
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds an agent. It does not perform any network I/O.
|
// New builds an agent. It does not perform any network I/O.
|
||||||
@@ -115,6 +117,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
go a.watchMain(ctx)
|
go a.watchMain(ctx)
|
||||||
|
go a.autoUpdateLoop(ctx)
|
||||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
||||||
|
|
||||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||||
@@ -321,6 +324,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
|||||||
a.applyTunnelHeader(ctx, resp.Header)
|
a.applyTunnelHeader(ctx, resp.Header)
|
||||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
|
a.applyUpdateHeaders(resp.Header)
|
||||||
a.dispatch(ctx, msg)
|
a.dispatch(ctx, msg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -356,6 +360,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
|
a.applyUpdateHeaders(resp.Header)
|
||||||
return a.applyTunnelHeader(ctx, resp.Header)
|
return a.applyTunnelHeader(ctx, resp.Header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ func (a *Agent) runWS(ctx context.Context) error {
|
|||||||
a.applyTunnelAddr(ctx, m.Tunnel)
|
a.applyTunnelAddr(ctx, m.Tunnel)
|
||||||
a.learnAddrs(m.Main, m.Fallback)
|
a.learnAddrs(m.Main, m.Fallback)
|
||||||
a.applyTransport(m.Transport)
|
a.applyTransport(m.Transport)
|
||||||
|
a.applyUpdateInfo(m.AutoUpdate, m.ServerVersion)
|
||||||
if a.mode() == proto.TransportLP {
|
if a.mode() == proto.TransportLP {
|
||||||
return errors.New("set to long polling")
|
return errors.New("set to long polling")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,19 @@ type WSMsg struct {
|
|||||||
Transport string `json:"transport,omitempty"`
|
Transport string `json:"transport,omitempty"`
|
||||||
Fallback string `json:"fallback,omitempty"`
|
Fallback string `json:"fallback,omitempty"`
|
||||||
Main string `json:"main,omitempty"`
|
Main string `json:"main,omitempty"`
|
||||||
|
// Auto-update of agents: how often (minutes) an agent compares its
|
||||||
|
// version with the server's and updates itself if the server's is newer;
|
||||||
|
// 0 or missing - it does not. ServerVersion is the server's.
|
||||||
|
AutoUpdate int `json:"autoupdate,omitempty"`
|
||||||
|
ServerVersion string `json:"server_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Headers with the same on the poll answer and on /api/agent/config.
|
||||||
|
const (
|
||||||
|
AutoUpdateHeader = "X-Auto-Update" // minutes, "0" or missing: off
|
||||||
|
ServerVersionHeader = "X-Server-Version" // the server's release version
|
||||||
|
)
|
||||||
|
|
||||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||||
// Reused for both PTY terminals and raw TCP port-forward streams.
|
// Reused for both PTY terminals and raw TCP port-forward streams.
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// versionParts reads "0.2.5" (a leading v and a "-suffix" are allowed) as
|
||||||
|
// numbers; ok is false for anything that is not a dotted number.
|
||||||
|
func versionParts(v string) (parts []int, ok bool) {
|
||||||
|
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
|
if i := strings.IndexAny(v, "-+"); i >= 0 {
|
||||||
|
v = v[:i]
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(v, ".") {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
parts = append(parts, n)
|
||||||
|
}
|
||||||
|
return parts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionNewer reports whether version a is higher than b. Anything that is
|
||||||
|
// not a dotted number (a build named "dev", an empty string) is never newer
|
||||||
|
// and never older - so a program is never "updated" down, or from a guess.
|
||||||
|
func VersionNewer(a, b string) bool {
|
||||||
|
pa, okA := versionParts(a)
|
||||||
|
pb, okB := versionParts(b)
|
||||||
|
if !okA || !okB {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(pa) || i < len(pb); i++ {
|
||||||
|
var x, y int
|
||||||
|
if i < len(pa) {
|
||||||
|
x = pa[i]
|
||||||
|
}
|
||||||
|
if i < len(pb) {
|
||||||
|
y = pb[i]
|
||||||
|
}
|
||||||
|
if x != y {
|
||||||
|
return x > y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A version is newer only when it is a higher dotted number: never for the
|
||||||
|
// same, an older one, or what is not a version at all.
|
||||||
|
func TestVersionNewer(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"0.2.5", "0.2.4", true},
|
||||||
|
{"0.2.10", "0.2.9", true},
|
||||||
|
{"0.3.0", "0.2.99", true},
|
||||||
|
{"1.0", "0.9.9", true},
|
||||||
|
{"0.2.4", "0.2.4", false},
|
||||||
|
{"0.2.3", "0.2.4", false},
|
||||||
|
{"0.2", "0.2.0", false},
|
||||||
|
{"v0.2.5", "0.2.4", true},
|
||||||
|
{"", "0.2.4", false},
|
||||||
|
{"dev", "0.2.4", false},
|
||||||
|
{"0.2.5", "dev", false},
|
||||||
|
{"0.2.5", "", false},
|
||||||
|
{"0.2.5-rc1", "0.2.4", true},
|
||||||
|
} {
|
||||||
|
if got := VersionNewer(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("VersionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -47,6 +47,12 @@ func aiNativeKey(id string) string { return "ai_native_" + id }
|
|||||||
// 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) }
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// shows (Short, up to 12 characters, on the button of the model in use), the
|
||||||
// kind of API, its address, model and key.
|
// kind of API, its address, model and key.
|
||||||
@@ -757,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.
|
||||||
@@ -775,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), " ")
|
||||||
@@ -833,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
|
||||||
}
|
}
|
||||||
@@ -944,6 +952,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
|
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
|
||||||
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
|
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
|
||||||
}
|
}
|
||||||
|
system += s.imagePrompt()
|
||||||
if in.Agent {
|
if in.Agent {
|
||||||
system += aiAgent
|
system += aiAgent
|
||||||
}
|
}
|
||||||
@@ -1146,7 +1155,8 @@ func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, m := range s.aiModels() {
|
for _, m := range s.aiModels() {
|
||||||
ms = append(ms, pick{m.ID, m.label(), m.Short})
|
ms = append(ms, pick{m.ID, m.label(), m.Short})
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"on": on != "0", "models": ms})
|
_, 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.
|
||||||
@@ -1214,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,389 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-update, two kinds, each off until asked for (Настройки › Автообновление):
|
||||||
|
// - of the agents: they are told how often to compare their version with
|
||||||
|
// the server's and, if the server's is newer, to take the agent from it
|
||||||
|
// (as the panel's «Обновить» does);
|
||||||
|
// - of the server: it compares itself with the latest release on GitLab;
|
||||||
|
// when that is newer, an administrator is offered it at login (after the
|
||||||
|
// PIN), and on «Обновить» the install script is run (the same one as
|
||||||
|
// `wget -qO- …/server-install.sh | sudo sh`). Nothing runs unasked.
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfgAgentAuto = "agent_autoupdate"
|
||||||
|
cfgAgentAutoMin = "agent_autoupdate_min"
|
||||||
|
cfgServerAuto = "server_autoupdate"
|
||||||
|
cfgServerAutoMin = "server_autoupdate_min"
|
||||||
|
|
||||||
|
// Where releases are. Not in the settings: for tests.
|
||||||
|
cfgUpdateRepo = "update_repo"
|
||||||
|
cfgUpdateBase = "update_base"
|
||||||
|
|
||||||
|
DefaultUpdateRepo = "itzredtea/lpra"
|
||||||
|
DefaultUpdateBase = "https://gitlab.com"
|
||||||
|
|
||||||
|
defaultAutoMin = 360 // 6 hours
|
||||||
|
maxAutoMin = 60 * 24 * 30 // 30 days
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoSetting reads an "on" flag and its interval in minutes.
|
||||||
|
func (s *Server) autoSetting(onKey, minKey string) (bool, int) {
|
||||||
|
on, _ := s.db.GetConfig(onKey)
|
||||||
|
m, _ := strconv.Atoi(func() string { v, _ := s.db.GetConfig(minKey); return v }())
|
||||||
|
if m < 1 || m > maxAutoMin {
|
||||||
|
m = defaultAutoMin
|
||||||
|
}
|
||||||
|
return on == "1", m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) agentAuto() (bool, int) { return s.autoSetting(cfgAgentAuto, cfgAgentAutoMin) }
|
||||||
|
func (s *Server) serverAuto() (bool, int) { return s.autoSetting(cfgServerAuto, cfgServerAutoMin) }
|
||||||
|
|
||||||
|
// setUpdateHeaders tells an agent the server's version and how often to
|
||||||
|
// compare ("0": never).
|
||||||
|
func (s *Server) setUpdateHeaders(w http.ResponseWriter) {
|
||||||
|
w.Header().Set(proto.ServerVersionHeader, Version)
|
||||||
|
w.Header().Set(proto.AutoUpdateHeader, strconv.Itoa(s.agentAutoMinutes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentAutoMinutes is the interval agents are given, 0 when off.
|
||||||
|
func (s *Server) agentAutoMinutes() int {
|
||||||
|
if on, m := s.agentAuto(); on {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// minutesOf turns "2" and "h" into 120: min, h, d.
|
||||||
|
func minutesOf(n, unit string) int {
|
||||||
|
v, err := strconv.Atoi(strings.TrimSpace(n))
|
||||||
|
if err != nil || v < 1 {
|
||||||
|
return defaultAutoMin
|
||||||
|
}
|
||||||
|
switch unit {
|
||||||
|
case "h":
|
||||||
|
v *= 60
|
||||||
|
case "d":
|
||||||
|
v *= 60 * 24
|
||||||
|
}
|
||||||
|
if v > maxAutoMin {
|
||||||
|
v = maxAutoMin
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /settings/autoupdate - both settings.
|
||||||
|
func (s *Server) handleSetAutoUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flag := func(v string) string {
|
||||||
|
if v == "1" {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgAgentAuto, flag(r.FormValue("agent_auto")))
|
||||||
|
_ = s.db.SetConfig(cfgAgentAutoMin, strconv.Itoa(minutesOf(r.FormValue("agent_every"), r.FormValue("agent_unit"))))
|
||||||
|
_ = s.db.SetConfig(cfgServerAuto, flag(r.FormValue("server_auto")))
|
||||||
|
_ = s.db.SetConfig(cfgServerAutoMin, strconv.Itoa(minutesOf(r.FormValue("server_every"), r.FormValue("server_unit"))))
|
||||||
|
// A server that was told to look again looks at once.
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.last = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
http.Redirect(w, r, "/settings#autoupdate", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intervalParts is a number of minutes as the form shows it: 120 - 2 h.
|
||||||
|
func intervalParts(min int) (int, string) {
|
||||||
|
switch {
|
||||||
|
case min%(60*24) == 0:
|
||||||
|
return min / (60 * 24), "d"
|
||||||
|
case min%60 == 0:
|
||||||
|
return min / 60, "h"
|
||||||
|
}
|
||||||
|
return min, "min"
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoView is what Настройки show of the settings.
|
||||||
|
type autoView struct {
|
||||||
|
AgentOn, ServerOn bool
|
||||||
|
AgentEvery, ServerEvery int
|
||||||
|
AgentUnit, ServerUnit string
|
||||||
|
Status updStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) autoView() autoView {
|
||||||
|
var v autoView
|
||||||
|
var am, sm int
|
||||||
|
v.AgentOn, am = s.agentAuto()
|
||||||
|
v.ServerOn, sm = s.serverAuto()
|
||||||
|
v.AgentEvery, v.AgentUnit = intervalParts(am)
|
||||||
|
v.ServerEvery, v.ServerUnit = intervalParts(sm)
|
||||||
|
v.Status = s.updateStatus()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// updState is what the updating of the server remembers.
|
||||||
|
type updState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
last time.Time // when it last looked
|
||||||
|
latest string
|
||||||
|
checked time.Time
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// updStatus is that, for the settings page.
|
||||||
|
type updStatus struct {
|
||||||
|
Current, Latest string
|
||||||
|
Checked string
|
||||||
|
Error string
|
||||||
|
Newer bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateStatus() updStatus {
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
defer s.upd.mu.Unlock()
|
||||||
|
st := updStatus{Current: Version, Latest: s.upd.latest, Error: s.upd.err, Newer: proto.VersionNewer(s.upd.latest, Version)}
|
||||||
|
if !s.upd.checked.IsZero() {
|
||||||
|
st.Checked = s.upd.checked.Format("02.01.2006 15:04")
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateRepoBase() (base, repo string) {
|
||||||
|
base, _ = s.db.GetConfig(cfgUpdateBase)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultUpdateBase
|
||||||
|
}
|
||||||
|
repo, _ = s.db.GetConfig(cfgUpdateRepo)
|
||||||
|
if repo == "" {
|
||||||
|
repo = DefaultUpdateRepo
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/"), repo
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
// latestRelease is the tag of the project's latest release on GitLab.
|
||||||
|
func (s *Server) latestRelease(ctx context.Context) (string, error) {
|
||||||
|
base, repo := s.updateRepoBase()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v4/projects/"+url.PathEscape(repo)+"/releases/permalink/latest", nil)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := updateClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("GitLab не отвечает: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", errors.New("GitLab ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil || out.Tag == "" {
|
||||||
|
return "", errors.New("не прочиталась версия последнего релиза")
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(out.Tag, "v"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRelease looks at GitLab and keeps what it found for the page.
|
||||||
|
func (s *Server) checkRelease(ctx context.Context) (string, error) {
|
||||||
|
tag, err := s.latestRelease(ctx)
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
defer s.upd.mu.Unlock()
|
||||||
|
s.upd.checked = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
s.upd.err = err.Error()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.upd.latest, s.upd.err = tag, ""
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installScript is the install script on the project's main branch.
|
||||||
|
func (s *Server) installScript(ctx context.Context) ([]byte, error) {
|
||||||
|
base, repo := s.updateRepoBase()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+repo+"/-/raw/main/server-install.sh", nil)
|
||||||
|
resp, err := updateClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("скрипт установки не скачался: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, errors.New("скрипт установки: GitLab ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
// What is run as root must be the script, not a page of an error.
|
||||||
|
if !strings.HasPrefix(string(b), "#!/bin/sh\n") || !strings.Contains(string(b), "LPRA server") {
|
||||||
|
return nil, errors.New("по адресу скрипта установки лежит не он")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServerUpdate takes the install script and has it run, apart from
|
||||||
|
// this process: it stops the service, and a script in the service's own
|
||||||
|
// control group would go with it.
|
||||||
|
func (s *Server) startServerUpdate(ctx context.Context) error {
|
||||||
|
script, err := s.installScript(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run := s.updRun
|
||||||
|
if run == nil {
|
||||||
|
run = runServerUpdate
|
||||||
|
}
|
||||||
|
return run(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runServerUpdate runs the script as a transient systemd unit of its own,
|
||||||
|
// in the folder this server lives in. Needs root and systemd.
|
||||||
|
func runServerUpdate(script []byte) error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return errors.New("сервер запущен не от root - обновить его самому нельзя, запустите server-install.sh")
|
||||||
|
}
|
||||||
|
sr, err := exec.LookPath("systemd-run")
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("нет systemd-run - автообновление сервера работает только под systemd")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
path := filepath.Join(dir, ".server-update.sh")
|
||||||
|
if err := os.WriteFile(path, script, 0o700); err != nil {
|
||||||
|
return errors.New("скрипт не записался: " + err.Error())
|
||||||
|
}
|
||||||
|
out, err := exec.Command(sr, "--unit=lpra-self-update", "--collect", "--setenv=LPRA_DIR="+dir,
|
||||||
|
"/bin/sh", "-c", "sh '"+path+"'; rm -f '"+path+"'").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return fmt.Errorf("systemd-run: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoUpdateTick: one look at the clock. When the server is to look at
|
||||||
|
// GitLab (on, and the interval is out) it does, and keeps what it found.
|
||||||
|
func (s *Server) autoUpdateTick(now time.Time) {
|
||||||
|
on, min := s.serverAuto()
|
||||||
|
if !on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
if !s.upd.last.IsZero() && now.Sub(s.upd.last) < time.Duration(min)*time.Minute {
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.upd.last = now
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = s.checkRelease(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAutoUpdate watches the clock until ctx ends.
|
||||||
|
func (s *Server) RunAutoUpdate(ctx context.Context) {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/check - looks at GitLab now.
|
||||||
|
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag, err := s.checkRelease(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "current": Version})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "current": Version, "latest": tag, "newer": proto.VersionNewer(tag, Version)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/run - updates the server now, to a newer release.
|
||||||
|
func (s *Server) handleUpdateRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag, err := s.checkRelease(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !proto.VersionNewer(tag, Version) {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": "уже последняя версия (" + Version + ")"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.startServerUpdate(r.Context()); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "latest": tag})
|
||||||
|
}
|
||||||
|
|
||||||
|
func declinedKey(id int64) string { return "update_declined_" + strconv.FormatInt(id, 10) }
|
||||||
|
|
||||||
|
// GET /api/panel/update/offer - whether this administrator is to be offered
|
||||||
|
// an update now: auto-update on, a release newer than this one, and not one
|
||||||
|
// they declined.
|
||||||
|
func (s *Server) handleUpdateOffer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
no := func() { writeJSON(w, map[string]any{"offer": false}) }
|
||||||
|
on, min := s.serverAuto()
|
||||||
|
if !on {
|
||||||
|
no()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not looked at lately (just started, or the interval is out): look now.
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
stale := s.upd.checked.IsZero() || time.Since(s.upd.checked) >= time.Duration(min)*time.Minute
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if stale {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
_, _ = s.checkRelease(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
st := s.updateStatus()
|
||||||
|
declined, _ := s.db.GetConfig(declinedKey(s.currentAdmin(r).ID))
|
||||||
|
if !st.Newer || declined == st.Latest {
|
||||||
|
no()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"offer": true, "current": st.Current, "latest": st.Latest})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/decline - {version}: this administrator is not to
|
||||||
|
// be asked about that release again.
|
||||||
|
func (s *Server) handleUpdateDecline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) == nil && len(in.Version) <= 32 {
|
||||||
|
_ = s.db.SetConfig(declinedKey(s.currentAdmin(r).ID), in.Version)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGitLab is the place releases come from: the latest release's tag and
|
||||||
|
// the install script on main.
|
||||||
|
type fakeGitLab struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
tag string
|
||||||
|
script string
|
||||||
|
hits int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeGitLab(t *testing.T, tag string) *fakeGitLab {
|
||||||
|
g := &fakeGitLab{tag: tag, script: "#!/bin/sh\n# LPRA server: install or update in one command.\necho updating\n"}
|
||||||
|
g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.hits++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/api/v4/projects/itzredtea%2Flpra/releases/permalink/latest":
|
||||||
|
// The real one sends the caller on to the release itself.
|
||||||
|
http.Redirect(w, r, "/api/v4/projects/1/releases/"+g.tag, http.StatusFound)
|
||||||
|
case "/api/v4/projects/1/releases/" + g.tag:
|
||||||
|
fmt.Fprintf(w, `{"tag_name":%q}`, g.tag)
|
||||||
|
case "/itzredtea/lpra/-/raw/main/server-install.sh":
|
||||||
|
fmt.Fprint(w, g.script)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(g.srv.Close)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func useFakeGitLab(s *Server, g *fakeGitLab) {
|
||||||
|
_ = s.db.SetConfig(cfgUpdateBase, g.srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A runner that only notes what it was given, in place of a real update.
|
||||||
|
type runs struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
scripts []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runs) run(script []byte) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.scripts = append(r.scripts, string(script))
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runs) n() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.scripts) }
|
||||||
|
|
||||||
|
// postAuto sends the form of Настройки › Автообновление.
|
||||||
|
func postAuto(t *testing.T, s *Server, c *http.Cookie, f url.Values) {
|
||||||
|
t.Helper()
|
||||||
|
f.Set("csrf", "tok")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/settings/autoupdate", strings.NewReader(f.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(c)
|
||||||
|
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save: %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings are kept in minutes whatever unit they were given in, and
|
||||||
|
// off unless asked for.
|
||||||
|
func TestAutoUpdateSettings(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
if on, _ := s.agentAuto(); on {
|
||||||
|
t.Fatal("agents update by themselves with nothing set")
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"2"}, "agent_unit": {"h"}, "server_auto": {"1"}, "server_every": {"1"}, "server_unit": {"d"}})
|
||||||
|
if on, m := s.agentAuto(); !on || m != 120 {
|
||||||
|
t.Fatalf("agents: %v %d", on, m)
|
||||||
|
}
|
||||||
|
if on, m := s.serverAuto(); !on || m != 1440 {
|
||||||
|
t.Fatalf("server: %v %d", on, m)
|
||||||
|
}
|
||||||
|
// Out of range or not a number: the default, or the largest.
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"x"}, "agent_unit": {"min"}, "server_every": {"999"}, "server_unit": {"d"}})
|
||||||
|
if _, m := s.agentAuto(); m != defaultAutoMin {
|
||||||
|
t.Fatalf("a bad number: %d", m)
|
||||||
|
}
|
||||||
|
if on, m := s.serverAuto(); on || m != maxAutoMin {
|
||||||
|
t.Fatalf("server off, a large number: %v %d", on, m)
|
||||||
|
}
|
||||||
|
body := get(t, s, c, "/settings?frag=1").Body.String()
|
||||||
|
if !strings.Contains(body, `id="autoupdate"`) || !strings.Contains(body, "Автообновление клиентов") || !strings.Contains(body, "Автообновление сервера") {
|
||||||
|
t.Fatal("the settings page has no auto-update card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents are told of it - on the config they fetch, and with their poll -
|
||||||
|
// together with the server's own version.
|
||||||
|
func TestAgentsToldOfAutoUpdate(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
if err := s.db.CreateAgent(Agent{ID: "a2", Name: "n", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: hashToken("tok2")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := func() http.Header {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/config", nil)
|
||||||
|
req.Header.Set("X-Agent-Id", "a2")
|
||||||
|
req.Header.Set("Authorization", "Bearer tok2")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("config: %d", w.Code)
|
||||||
|
}
|
||||||
|
return w.Header()
|
||||||
|
}
|
||||||
|
h := cfg()
|
||||||
|
if h.Get(proto.AutoUpdateHeader) != "0" || h.Get(proto.ServerVersionHeader) != Version {
|
||||||
|
t.Fatalf("off: %v", h)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"90"}, "agent_unit": {"min"}})
|
||||||
|
if h := cfg(); h.Get(proto.AutoUpdateHeader) != "90" {
|
||||||
|
t.Fatalf("on: %v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest release is read from GitLab, through its redirect.
|
||||||
|
func TestLatestRelease(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "9.9.9")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
tag, err := s.latestRelease(t.Context())
|
||||||
|
if err != nil || tag != "9.9.9" {
|
||||||
|
t.Fatalf("latest: %q %v", tag, err)
|
||||||
|
}
|
||||||
|
g.srv.Close()
|
||||||
|
if _, err := s.latestRelease(t.Context()); err == nil {
|
||||||
|
t.Fatal("no error with GitLab out of reach")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With auto-update on the server looks at GitLab by itself, at the interval,
|
||||||
|
// and only looks: updating is the administrator's to say yes to.
|
||||||
|
func TestServerAutoUpdateTick(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
if g.hits != 0 {
|
||||||
|
t.Fatalf("off, yet it looked: %d hits", g.hits)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
if st := s.updateStatus(); st.Latest != "99.0.0" || !st.Newer {
|
||||||
|
t.Fatalf("it did not learn of the release: %+v", st)
|
||||||
|
}
|
||||||
|
hits := g.hits
|
||||||
|
s.autoUpdateTick(now.Add(10 * time.Minute))
|
||||||
|
if g.hits != hits {
|
||||||
|
t.Fatal("it looked again before the interval was out")
|
||||||
|
}
|
||||||
|
s.autoUpdateTick(now.Add(61 * time.Minute))
|
||||||
|
if g.hits == hits {
|
||||||
|
t.Fatal("it did not look when the interval was out")
|
||||||
|
}
|
||||||
|
if r.n() != 0 {
|
||||||
|
t.Fatalf("it updated by itself: %d runs", r.n())
|
||||||
|
}
|
||||||
|
// GitLab out of reach: told on the page, kept until it answers.
|
||||||
|
g.srv.Close()
|
||||||
|
s.autoUpdateTick(now.Add(130 * time.Minute))
|
||||||
|
if st := s.updateStatus(); st.Error == "" {
|
||||||
|
t.Fatalf("the failure is not kept: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an administrator is offered at login: a newer release, with
|
||||||
|
// auto-update on, not one they said no to - and again when a newer one comes.
|
||||||
|
func TestUpdateOffer(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
offer := func() string { return apiCall(t, s, c, "GET", "/api/panel/update/offer", "") }
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("auto-update off: %s", got)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||||
|
got := offer()
|
||||||
|
if !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||||
|
t.Fatalf("a newer release: %s", got)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/update/decline", `{"version":"99.0.0"}`)
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("after saying no: %s", got)
|
||||||
|
}
|
||||||
|
g.tag = "99.1.0"
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.checked = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.1.0"`) {
|
||||||
|
t.Fatalf("a still newer release: %s", got)
|
||||||
|
}
|
||||||
|
g.tag = Version
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.checked = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("nothing newer: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything but the install script is not run: a page of GitLab's, say.
|
||||||
|
func TestUpdateScriptChecked(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
g.script = "<html>502 Bad Gateway</html>"
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
if err := s.startServerUpdate(t.Context()); err == nil || r.n() != 0 {
|
||||||
|
t.Fatalf("a page was run as a script: %v, %d runs", err, r.n())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the panel: check now, and update now - only to a newer release.
|
||||||
|
func TestUpdatePanelButtons(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/update/check", "")
|
||||||
|
if !strings.Contains(got, `"newer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||||
|
t.Fatalf("check: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":true`) || r.n() != 1 {
|
||||||
|
t.Fatalf("run: %s, %d runs", got, r.n())
|
||||||
|
}
|
||||||
|
g.tag = Version
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":false`) || r.n() != 1 {
|
||||||
|
t.Fatalf("run with nothing newer: %s, %d runs", got, r.n())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not for everybody: only an administrator updates the server.
|
||||||
|
func TestUpdateOnlyForAdmins(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
if a == nil {
|
||||||
|
t.Fatal("no admin")
|
||||||
|
}
|
||||||
|
// The test's user is an administrator; the guard is the route's own.
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/check", ""); strings.Contains(got, "только для администраторов") {
|
||||||
|
t.Fatalf("an administrator was refused: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
||||||
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
s.setUpdateHeaders(w)
|
||||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
writeJSON(w, msg)
|
writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
s.setUpdateHeaders(w)
|
||||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,6 +445,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"AIModels": s.aiModels(),
|
"AIModels": s.aiModels(),
|
||||||
"Search": s.searchConf(),
|
"Search": s.searchConf(),
|
||||||
|
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
|
||||||
|
"Auto": s.autoView(),
|
||||||
"DBNow": dbNow,
|
"DBNow": dbNow,
|
||||||
"DBChosen": dbChosen,
|
"DBChosen": dbChosen,
|
||||||
"UfwInstalled": ufwInstalled,
|
"UfwInstalled": ufwInstalled,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ type Server struct {
|
|||||||
wsAgents wsAgents // agents on their WebSocket now
|
wsAgents wsAgents // agents on their WebSocket now
|
||||||
pinMisses pinMisses // wrong PINs per session
|
pinMisses pinMisses // wrong PINs per session
|
||||||
assets map[string]string // static file -> hash of its content
|
assets map[string]string // static file -> hash of its content
|
||||||
|
|
||||||
|
upd updState // the server's own updating
|
||||||
|
updRun func(script []byte) error // runs the install script; the real one unless a test sets it
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server, parsing embedded templates.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -266,7 +269,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
|
mux.Handle("POST /api/panel/ai/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/autoupdate", s.adminsOnly(s.handleSetAutoUpdate))
|
||||||
|
mux.Handle("POST /api/panel/update/check", s.adminsOnly(s.handleUpdateCheck))
|
||||||
|
mux.Handle("GET /api/panel/update/offer", s.adminsOnly(s.handleUpdateOffer))
|
||||||
|
mux.Handle("POST /api/panel/update/decline", s.adminsOnly(s.handleUpdateDecline))
|
||||||
|
mux.Handle("POST /api/panel/update/run", s.adminsOnly(s.handleUpdateRun))
|
||||||
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
|
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
|
||||||
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
|
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
|
||||||
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
|
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
|
||||||
|
|||||||
@@ -110,8 +110,14 @@
|
|||||||
// The models to choose from come with the state, again on every opening
|
// The models to choose from come with the state, again on every opening
|
||||||
// (an admin may have changed them).
|
// (an admin may have changed them).
|
||||||
let models = [];
|
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())
|
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
|
||||||
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
|
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
|
||||||
loadState();
|
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(); };
|
||||||
@@ -237,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();
|
||||||
@@ -247,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;
|
||||||
@@ -367,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 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
||||||
@@ -421,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
|
||||||
@@ -467,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)$/;
|
||||||
@@ -514,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/')) {
|
||||||
@@ -550,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', 'Видео'];
|
||||||
@@ -566,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';
|
||||||
@@ -575,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) : '');
|
||||||
@@ -621,8 +721,56 @@
|
|||||||
// the next question (or by note, when done by hand).
|
// the next question (or by note, when done by hand).
|
||||||
function showResult(text) {
|
function showResult(text) {
|
||||||
const el = add('result', '');
|
const el = add('result', '');
|
||||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
// A picture made: shown here, from its marker (kept with the talk, so
|
||||||
el.querySelector('pre').textContent = text;
|
// it comes back with it) - and what the next edit works on. Its words
|
||||||
|
// are the model's alone: the user sees the picture.
|
||||||
|
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
|
||||||
|
if (!m) {
|
||||||
|
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||||
|
el.querySelector('pre').textContent = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPic = m[1];
|
||||||
|
const follow = nearBottom();
|
||||||
|
const link = document.createElement('a'), img = document.createElement('img');
|
||||||
|
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
|
||||||
|
img.alt = 'Картинка'; img.src = m[1];
|
||||||
|
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
|
||||||
|
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
|
||||||
|
link.appendChild(img);
|
||||||
|
el.classList.add('pic');
|
||||||
|
el.appendChild(link);
|
||||||
|
}
|
||||||
|
// makeImage: a picture asked for in an answer - put at the image service
|
||||||
|
// through the server, asked about until it is done. What comes back is
|
||||||
|
// what the model is told: that it is made and shown (or why not).
|
||||||
|
async function makeImage(a, signal) {
|
||||||
|
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
|
||||||
|
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
|
||||||
|
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
|
||||||
|
if (a.edit) {
|
||||||
|
const want = (a.file || '').toLowerCase();
|
||||||
|
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
|
||||||
|
if (pick) body.image = {mime: pick.mime, data: pick.data};
|
||||||
|
else if (lastPic) body.image_url = lastPic;
|
||||||
|
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
|
||||||
|
}
|
||||||
|
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||||
|
if (!r.ok) return 'Ошибка картинки: ' + r.error;
|
||||||
|
let lost = 0;
|
||||||
|
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
|
||||||
|
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
|
||||||
|
if (!s.ok) {
|
||||||
|
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
|
||||||
|
return 'Ошибка картинки: ' + s.error;
|
||||||
|
}
|
||||||
|
if (s.status === 'done') {
|
||||||
|
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
|
||||||
|
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
|
||||||
|
}
|
||||||
|
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
|
||||||
|
}
|
||||||
|
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
|
||||||
}
|
}
|
||||||
list.addEventListener('click', async ev => {
|
list.addEventListener('click', async ev => {
|
||||||
const b = ev.target.closest('.ai-act');
|
const b = ev.target.closest('.ai-act');
|
||||||
@@ -632,7 +780,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})});
|
||||||
});
|
});
|
||||||
@@ -766,21 +914,24 @@
|
|||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -954,6 +958,12 @@ 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 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. */
|
||||||
@@ -1091,7 +1101,7 @@ details[open]::details-content { block-size: auto; opacity: 1; }
|
|||||||
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
|
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
|
||||||
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
|
.ai-code-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:baseline; 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 { align-self:center; 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; }
|
||||||
@@ -1100,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); }
|
||||||
@@ -1134,6 +1156,13 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
|
|||||||
.ai-model-sub { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12.5px; }
|
.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-sub:not(:empty)::before { content:'· '; }
|
||||||
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
|
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
|
||||||
|
.ai-form label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
|
.ai-form label.check input { margin:0; }
|
||||||
|
.auto-every { display:flex; align-items:center; gap:8px; }
|
||||||
|
.auto-every input[type=number] { width:84px; flex:none; }
|
||||||
|
.auto-every select { width:auto; flex:none; }
|
||||||
|
.auto-status { font-size:13px; line-height:1.5; }
|
||||||
|
.auto-status .bad { color:var(--danger); }
|
||||||
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:2px 0 4px; }
|
.ai-model-head { 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-name { flex:1; min-width:0; margin:0; }
|
||||||
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
|
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
|
||||||
|
|||||||
@@ -23,6 +23,30 @@
|
|||||||
}, e => alert('Не скопировалось: ' + e.message));
|
}, e => alert('Не скопировалось: ' + e.message));
|
||||||
});
|
});
|
||||||
if (window.UsersTable) window.UsersTable.mount(root);
|
if (window.UsersTable) window.UsersTable.mount(root);
|
||||||
|
// Автообновление: look at GitLab now, and update now.
|
||||||
|
const au = root.querySelector('#autoupdate .auto-check');
|
||||||
|
if (au) {
|
||||||
|
const st = root.querySelector('#autoupdate .auto-status'), run = root.querySelector('#autoupdate .auto-run');
|
||||||
|
const post = u => fetch(u, {method: 'POST', credentials: 'same-origin'}).then(r => r.json()).catch(() => ({ok: false, error: 'нет связи с сервером'}));
|
||||||
|
const say = (text, bad) => { st.textContent = ''; const s = document.createElement('span'); s.textContent = text; if (bad) s.className = 'bad'; st.appendChild(s); };
|
||||||
|
au.onclick = async () => {
|
||||||
|
au.disabled = true; say('Проверяю…');
|
||||||
|
const r = await post('/api/panel/update/check');
|
||||||
|
au.disabled = false;
|
||||||
|
if (!r.ok) { say(r.error, true); return; }
|
||||||
|
say('Версия сервера: ' + r.current + ' · на GitLab: ' + r.latest + (r.newer ? ' - вышла новая' : ' - это последняя'));
|
||||||
|
run.hidden = !r.newer;
|
||||||
|
};
|
||||||
|
run.onclick = async () => {
|
||||||
|
if (!await UI.confirm('Обновить сервер сейчас? Панель будет недоступна около минуты.', {ok: 'Обновить'})) return;
|
||||||
|
run.disabled = true; say('Запускаю обновление…');
|
||||||
|
const r = await post('/api/panel/update/run');
|
||||||
|
run.disabled = false;
|
||||||
|
if (!r.ok) { say(r.error, true); return; }
|
||||||
|
run.hidden = true;
|
||||||
|
say('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу.');
|
||||||
|
};
|
||||||
|
}
|
||||||
// ИИ-ассистент: a card per model, added from the blank one and taken
|
// ИИ-ассистент: a card per model, added from the blank one and taken
|
||||||
// away by its «Удалить» (the last one stays).
|
// away by its «Удалить» (the last one stays).
|
||||||
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
|
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
input.value = o.value || '';
|
input.value = o.value || '';
|
||||||
yes.textContent = o.ok || 'OK';
|
yes.textContent = o.ok || 'OK';
|
||||||
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
|
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
|
||||||
|
dlg.querySelector('.ui-no').textContent = o.cancel || 'Отмена';
|
||||||
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
|
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
|
||||||
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
|
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
|
||||||
return new Promise(done => {
|
return new Promise(done => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// A new release of the server: an administrator is offered it when they come
|
||||||
|
// in - after the PIN, or at once when there is none - once for each tab. The
|
||||||
|
// window says the versions and has «Обновить» and «Отказаться»; a refusal is
|
||||||
|
// kept for that release (a still newer one asks again). Only when the server
|
||||||
|
// is set to look for releases (Настройки › Автообновление).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var root = document.documentElement;
|
||||||
|
var SHOWN = 'lpra.upd.shown';
|
||||||
|
|
||||||
|
// The PIN lock hides the page until it is unlocked.
|
||||||
|
function whenUnlocked(fn) {
|
||||||
|
if (!root.classList.contains('pin-wait')) { fn(); return; }
|
||||||
|
var mo = new MutationObserver(function () {
|
||||||
|
if (!root.classList.contains('pin-wait')) { mo.disconnect(); fn(); }
|
||||||
|
});
|
||||||
|
mo.observe(root, {attributes: true, attributeFilter: ['class']});
|
||||||
|
}
|
||||||
|
function toast(text, bad) {
|
||||||
|
if (window.FileViews && FileViews.toast) FileViews.toast(text, bad);
|
||||||
|
}
|
||||||
|
function post(url, body) {
|
||||||
|
return fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body || {})}).then(function (r) { return r.json(); })
|
||||||
|
.catch(function () { return {ok: false, error: 'нет связи с сервером'}; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function offer() {
|
||||||
|
try { if (sessionStorage.getItem(SHOWN) === '1') return; } catch (e) { /* offered again */ }
|
||||||
|
fetch('/api/panel/update/offer', {cache: 'no-store', credentials: 'same-origin'})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.offer || !window.UI) return;
|
||||||
|
try { sessionStorage.setItem(SHOWN, '1'); } catch (e) { /* not remembered */ }
|
||||||
|
UI.confirm('Вышла новая версия LPRA {chip}\nОбновить сервер сейчас? Панель будет недоступна около минуты.',
|
||||||
|
{ok: 'Обновить', cancel: 'Отказаться', chip: d.current + ' → ' + d.latest}).then(function (yes) {
|
||||||
|
if (!yes) { post('/api/panel/update/decline', {version: d.latest}); return; }
|
||||||
|
post('/api/panel/update/run').then(function (r) {
|
||||||
|
if (r.ok) toast('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу');
|
||||||
|
else toast(r.error || 'обновить не получилось', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { /* no offer */ });
|
||||||
|
}
|
||||||
|
whenUnlocked(offer);
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -213,6 +213,7 @@
|
|||||||
<script src="{{asset "account.js"}}"></script>
|
<script src="{{asset "account.js"}}"></script>
|
||||||
<script src="{{asset "machine.js"}}"></script>
|
<script src="{{asset "machine.js"}}"></script>
|
||||||
<script src="{{asset "ai.js"}}"></script>
|
<script src="{{asset "ai.js"}}"></script>
|
||||||
|
<script src="{{asset "updatenote.js"}}"></script>
|
||||||
<script>App.start();</script>
|
<script>App.start();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
@@ -202,6 +202,42 @@
|
|||||||
<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>
|
</div></details>
|
||||||
|
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
|
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
|
||||||
|
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||||
|
</div></details>
|
||||||
|
<div><button type="submit">Сохранить</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="autoupdate">
|
||||||
|
<h2>Автообновление</h2>
|
||||||
|
<form method="post" action="/settings/autoupdate" class="ai-form">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
{{/* The same spoilers as the assistant's. */}}
|
||||||
|
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление клиентов</span><span class="ai-model-sub">{{if .Auto.AgentOn}}каждые {{.Auto.AgentEvery}} {{if eq .Auto.AgentUnit "d"}}сут.{{else if eq .Auto.AgentUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}}</span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
|
<p class="muted" style="margin:0">Клиенты раз в указанное время сверяют свою версию с версией сервера и, если у сервера новее, обновляются сами - так же, как по кнопке «Обновить». Нужны права root или sudo без пароля; агентам старше 0.2.5 один раз обновиться придётся вручную.</p>
|
||||||
|
<label class="check"><input type="checkbox" name="agent_auto" value="1"{{if .Auto.AgentOn}} checked{{end}}> Включить автообновление клиентов</label>
|
||||||
|
<div class="auto-every"><span class="muted">Проверять раз в</span>
|
||||||
|
<input type="number" name="agent_every" min="1" max="999" value="{{.Auto.AgentEvery}}">
|
||||||
|
<select name="agent_unit"><option value="min"{{if eq .Auto.AgentUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.AgentUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.AgentUnit "d"}} selected{{end}}>суток</option></select></div>
|
||||||
|
</div></details>
|
||||||
|
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление сервера</span><span class="ai-model-sub">{{if .Auto.ServerOn}}каждые {{.Auto.ServerEvery}} {{if eq .Auto.ServerUnit "d"}}сут.{{else if eq .Auto.ServerUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}} · сейчас {{.Auto.Status.Current}}</span></summary>
|
||||||
|
<div class="ai-model-body">
|
||||||
|
<p class="muted" style="margin:0">Сервер раз в указанное время сверяет свою версию с последним релизом на GitLab. Если вышел новый, администратору при входе (после PIN) показывается окно: версия и кнопки «Обновить» и «Отказаться». «Обновить» запускает тот же скрипт, что и вручную: <code>wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh</code> - от root, поэтому нужны root и systemd; панель при этом недоступна около минуты.</p>
|
||||||
|
<label class="check"><input type="checkbox" name="server_auto" value="1"{{if .Auto.ServerOn}} checked{{end}}> Предлагать обновление сервера при входе</label>
|
||||||
|
<div class="auto-every"><span class="muted">Проверять раз в</span>
|
||||||
|
<input type="number" name="server_every" min="1" max="999" value="{{.Auto.ServerEvery}}">
|
||||||
|
<select name="server_unit"><option value="min"{{if eq .Auto.ServerUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.ServerUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.ServerUnit "d"}} selected{{end}}>суток</option></select></div>
|
||||||
|
<div class="auto-status muted" aria-live="polite">Версия сервера: <b>{{.Auto.Status.Current}}</b>{{if .Auto.Status.Latest}} · на GitLab: <b>{{.Auto.Status.Latest}}</b>{{end}}{{if .Auto.Status.Checked}} · проверено {{.Auto.Status.Checked}}{{end}}{{if .Auto.Status.Error}}<br><span class="bad">{{.Auto.Status.Error}}</span>{{end}}</div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="btn ghost small auto-check">Проверить сейчас</button>
|
||||||
|
<button type="button" class="btn small auto-run"{{if not .Auto.Status.Newer}} hidden{{end}}>Обновить сейчас</button>
|
||||||
|
</div>
|
||||||
|
</div></details>
|
||||||
<div><button type="submit">Сохранить</button></div>
|
<div><button type="submit">Сохранить</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return wsjson.Write(wctx, conn, m)
|
return wsjson.Write(wctx, conn, m)
|
||||||
}
|
}
|
||||||
cfg := func() proto.WSMsg {
|
cfg := func() proto.WSMsg {
|
||||||
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()}
|
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress(),
|
||||||
|
AutoUpdate: s.agentAutoMinutes(), ServerVersion: Version}
|
||||||
}
|
}
|
||||||
if send(cfg()) != nil {
|
if send(cfg()) != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in new issue
Block a user