8 Commits
22 changed files with 1095 additions and 51 deletions

No files matched your search

+7
View File
@@ -53,6 +53,13 @@
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
+1 -1
View File
@@ -1 +1 @@
0.2.2
0.2.4
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+14 -2
View File
@@ -47,6 +47,12 @@ func aiNativeKey(id string) string { return "ai_native_" + id }
// default; an explicit "0" means the user turned it off.
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
// shows (Short, up to 12 characters, on the button of the model in use), the
// 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_chats WHERE id = ? AND admin_id = ?`, chat, owner)
s.db.wipeDeleted()
}
// 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()
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
s.db.wipeDeleted()
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
if role == "user" {
t := strings.Join(strings.Fields(content), " ")
@@ -833,7 +841,7 @@ func (s *Server) aiAdopt(owner int64) {
// text with "[ошибка: …]".
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on == "0" {
if !s.aiOn(a.ID) {
http.Error(w, "ассистент выключен в профиле", http.StatusForbidden)
return
}
@@ -944,6 +952,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
}
system += s.imagePrompt()
if in.Agent {
system += aiAgent
}
@@ -1146,7 +1155,8 @@ func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
for _, m := range s.aiModels() {
ms = append(ms, pick{m.ID, m.label(), m.Short})
}
writeJSON(w, map[string]any{"on": on != "0", "models": ms})
_, 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.
@@ -1214,6 +1224,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
_ = s.db.SetConfig(cfgSearchEngine, eng)
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
// The key of the image service («Генерация изображений»).
_ = s.db.SetConfig(cfgImageKey, strings.TrimSpace(r.FormValue("ai_image_key")))
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The assistant is on unless the user turned it off - for the web search
// and page reading as for the chat (a user who never touched the switch
// has no setting at all).
func TestAISearchAndFetchOnByDefault(t *testing.T) {
s, c := newTestServer(t)
sx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"results":[{"title":"Intel","url":"https://example.com/intel","content":"процессоры"}]}`)
}))
defer sx.Close()
_ = s.db.SetConfig(cfgSearchEngine, "searxng")
_ = s.db.SetConfig(cfgSearchURL, sx.URL)
post := func(path, body string) string {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := post("/api/panel/ai/search", `{"q":"intel"}`); !strings.Contains(got, `"ok":true`) {
t.Fatalf("search by default: %s", got)
}
if got := post("/api/panel/ai/fetch", `{"url":"не адрес"}`); strings.Contains(got, "выключен") {
t.Fatalf("fetch by default: %s", got)
}
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
for _, p := range []string{"/api/panel/ai/search", "/api/panel/ai/fetch"} {
if got := post(p, `{"q":"intel","url":"https://example.com"}`); !strings.Contains(got, "выключен") {
t.Fatalf("%s with the assistant off: %s", p, got)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// A chat deleted takes with it what the user attached to it: the text of
// its files lives in the messages, and must be gone from the database
// files too - not just from the tables (SQLite leaves deleted rows in its
// free pages and in the write-ahead log).
func TestDeleteChatWipesAttachments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.db")
db, err := OpenDB(path)
if err != nil {
t.Fatal(err)
}
s, err := New(Options{DB: db, DistDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
adm, _ := db.AdminByUsername("adm")
me := adm.ID
const secret = "TOP-SECRET-CONTENT-OF-AN-ATTACHED-FILE-7731"
chat := s.aiNewChat(me)
other := s.aiNewChat(me)
s.aiAppendTo(me, chat, "user", "посмотри\n\n[Вложение: notes.txt]\nСодержимое файла:\n"+secret)
s.aiAppendTo(me, chat, "result", "Картинка готова [картинка: https://s2.x-grk.com/uploads/userphoto/ai/zzz-7731.png]")
s.aiAppendTo(me, other, "user", "другой чат, остаётся")
s.aiDeleteChat(me, chat)
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, chat).Scan(&n)
if n != 0 {
t.Fatalf("%d messages of the deleted chat remain", n)
}
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, other).Scan(&n)
if n != 1 {
t.Fatalf("the other chat has %d messages, want 1", n)
}
for _, f := range []string{path, path + "-wal"} {
b, _ := os.ReadFile(f)
if bytes.Contains(b, []byte(secret)) || bytes.Contains(b, []byte("zzz-7731")) {
t.Fatalf("the deleted chat's content is still in %s", filepath.Base(f))
}
}
}
// The oldest chats go past the limit the same way: nothing of them is kept.
func TestOldChatsWipedToo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.db")
db, err := OpenDB(path)
if err != nil {
t.Fatal(err)
}
s, err := New(Options{DB: db, DistDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
adm, _ := db.AdminByUsername("adm")
me := adm.ID
first := s.aiNewChat(me)
s.aiAppendTo(me, first, "user", "[Вложение: a.txt]\nСодержимое файла:\nOLD-ATTACHMENT-TEXT-5512")
for i := 0; i < aiChatsKeep; i++ {
s.aiNewChat(me)
}
if s.aiOwns(me, first) {
t.Fatal("the oldest chat is kept past the limit")
}
for _, f := range []string{path, path + "-wal"} {
b, _ := os.ReadFile(f)
if bytes.Contains(b, []byte("OLD-ATTACHMENT-TEXT-5512")) {
t.Fatalf("the old chat's content is still in %s", filepath.Base(f))
}
}
}
+13 -1
View File
@@ -20,7 +20,7 @@ type DB struct {
func OpenDB(path string) (*DB, error) {
// _pragma busy_timeout avoids "database is locked" under the concurrent
// long-poll load; foreign_keys keeps the schema honest.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
sdb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
@@ -754,3 +754,15 @@ func boolInt(b bool) int {
}
return 0
}
// wipeDeleted makes what was deleted leave the database's files: with
// secure_delete the freed pages are overwritten, and the write-ahead log,
// which still holds the rows as they were written, is emptied (SQLite only;
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
// among that.
func (db *DB) wipeDeleted() {
if db.sql.pg {
return
}
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
+1 -1
View File
@@ -179,7 +179,7 @@ func pageText(page string) (string, string) {
// assistant on.
func (s *Server) handleAIFetch(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
if !s.aiOn(a.ID) {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
+28 -9
View File
@@ -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 {
select {
case <-sess.Done():
return
case out, ok := <-sess.Out():
if !ok || out.Exit {
if !ok || !write(out) {
return
}
if out.Data != "" {
b, err := base64.StdEncoding.DecodeString(out.Data)
if err != nil {
return
}
if _, err := conn.Write(b); err != nil {
case <-sess.Done():
// The agent's end of stream closes the session at once, while
// what it sent before may still be queued (a download read
// faster than the browser takes it): that goes out first.
for {
select {
case out := <-sess.Out():
if !write(out) {
return
}
default:
return
}
}
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"bytes"
"encoding/base64"
"io"
"net"
"testing"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// An http-lp download the agent has finished (its last chunk, then the end)
// reaches the browser whole: the session closing does not cut off what is
// still queued for it.
func TestForwardHTTPDrainsAfterClose(t *testing.T) {
hub := NewHub()
m := &forwardManager{hub: hub}
user, host := net.Pipe()
go m.bridgeHTTP(ForwardRule{AgentID: "a1", ClientPort: 80, Proto: "http"}, host)
var sess *Session
for i := 0; i < 100 && sess == nil; i++ {
hub.mu.Lock()
for _, s := range hub.sessions {
sess = s
}
hub.mu.Unlock()
time.Sleep(10 * time.Millisecond)
}
if sess == nil {
t.Fatal("no session")
}
want := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64 KiB in 64 chunks
go func() {
for i := 0; i < len(want); i += 1024 {
sess.PushOut(proto.OutMessage{Data: base64.StdEncoding.EncodeToString(want[i : i+1024])})
}
sess.PushOut(proto.OutMessage{Exit: true})
sess.close() // as the server does on the agent's end of stream
}()
time.Sleep(50 * time.Millisecond) // all of it queued, the session closed, before the browser reads
_ = user.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(user)
if !bytes.Equal(got, want) {
t.Fatalf("got %d of %d bytes", len(got), len(want))
}
}
+1
View File
@@ -445,6 +445,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"ServerVersion": Version,
"AIModels": s.aiModels(),
"Search": s.searchConf(),
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled,
+302
View File
@@ -0,0 +1,302 @@
package server
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// Pictures for the assistant: the model asks for one in its answer - a
// ```image block, a new picture or an edit of a photo - and the panel makes
// it through the Vertex API (https://s2.x-grk.com/api/vertex/docs). The
// work is a job there: it is put, then asked about until it is done. The
// API key is the admins', set in Настройки and never sent to the browser.
const (
cfgImageKey = "ai_image_key"
cfgImageURL = "ai_image_url" // not in the settings: the service's address, for tests
// DefaultImageURL is where the Vertex API is.
DefaultImageURL = "https://s2.x-grk.com"
imageMaxBytes = 20 << 20 // what the service takes of one photo
)
// imageConf is the service's address and the key (none: pictures are off).
func (s *Server) imageConf() (base, key string) {
base, _ = s.db.GetConfig(cfgImageURL)
if base == "" {
base = DefaultImageURL
}
key, _ = s.db.GetConfig(cfgImageKey)
return strings.TrimRight(base, "/"), strings.TrimSpace(key)
}
// imagePrompt tells the model it can make pictures, and how - when there
// is a key.
func (s *Server) imagePrompt() string {
if _, key := s.imageConf(); key == "" {
return ""
}
return "\n\nКартинки. Ты умеешь рисовать и править фото: сам, по смыслу разговора, без просьбы «вызови инструмент» и без лишних вопросов, " +
"когда пользователь просит нарисовать, сгенерировать, изобразить, сделать картинку, арт, логотип, обои, иллюстрацию " +
"или изменить, дорисовать, убрать, заменить что-то на фото.\n" +
"```image\nописание картинки\n``` - создать новую. Опиши сцену, стиль, свет, детали; язык описания любой.\n" +
"```image edit\nчто изменить на фото\n``` - править фото пользователя: то, что приложено к его сообщению " +
"(приложено несколько - после edit назови файл: ```image edit имя.jpg```); фото нет - последнюю созданную картинку.\n" +
"В первой строке блока можно задать формат: 1:1 (по умолчанию), 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, auto - и качество: " +
"fast (по умолчанию, 1 кредит), artist (5, лучше), 0.2plus (10, лучшее); повышай качество, только если пользователь просит. " +
"Например: ```image 16:9 artist```.\n" +
"Сервис по умолчанию сам улучшает описание (дописывает детали и стиль) - это хорошо для короткого запроса. " +
"Если пользователь дал готовое, точное описание (промпт целиком, логотип «ровно как сказано», текст на картинке, строгая композиция) " +
"или просит ничего не добавлять - добавь noenhance: ```image noenhance 3:2```. Для правки фото это не применяется.\n" +
"Картинку покажет сама панель: ссылку в ответ не вставляй, скажи коротко, что получилось, и предложи поправить. " +
"Делается она до пары минут. Одно действие за ответ."
}
// imageError says in Russian what an error code of the service means.
func imageError(code, msg string) string {
switch code {
case "unauthorized":
return "сервис не принял API-ключ - проверьте его в Настройки › ИИ-ассистент › Генерация изображений"
case "banned":
return "аккаунт сервиса картинок заблокирован"
case "insufficient_credits":
return "на аккаунте сервиса картинок не хватает кредитов"
case "daily_limit":
return "исчерпан дневной лимит картинок"
case "job_busy":
return "уже делается другая картинка - дождитесь её"
case "invalid_prompt":
return "сервис не принял описание (пустое или длиннее 4000 символов)"
case "invalid_image":
return "сервис не принял фото (нужен png, jpg или webp)"
case "image_too_large":
return "фото больше 20 МБ"
case "tokens_exhausted":
return "сервис картинок перегружен - попробуйте позже"
case "generation_failed", "edit_failed":
return "сервис не справился с картинкой (кредиты вернутся)"
case "source_missing":
return "исходное фото потерялось на сервисе - загрузите его заново"
}
if msg == "" {
msg = "сервис картинок ответил ошибкой"
}
return msg
}
var imageClient = &http.Client{Timeout: 90 * time.Second}
// imageCall sends the request to the service with the key and reads its
// JSON; an error is words for the user.
func imageCall(ctx context.Context, req *http.Request, key string, v any) error {
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := imageClient.Do(req)
if err != nil {
return errors.New("сервис картинок не отвечает: " + err.Error())
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var e struct {
OK *bool `json:"ok"`
ErrorCode string `json:"error_code"`
Error string `json:"error"`
}
if json.Unmarshal(b, &e) != nil {
return errors.New("сервис картинок ответил " + resp.Status)
}
if (e.OK != nil && !*e.OK) || resp.StatusCode/100 != 2 {
return errors.New(imageError(e.ErrorCode, e.Error))
}
return json.Unmarshal(b, v)
}
var (
imageRatios = map[string]bool{"1:1": true, "16:9": true, "9:16": true, "3:2": true, "2:3": true, "4:3": true, "3:4": true, "auto": true}
imageModels = map[string]bool{"fast": true, "artist": true, "0.2plus": true}
imageJobRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
imageExt = map[string]string{"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
)
// POST /api/panel/ai/image - {mode: generate|edit, prompt, model, ratio,
// image: {mime, data (base64)} | image_url}: puts the job, answers its id.
func (s *Server) handleAIImage(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
if !s.aiOn(s.currentAdmin(r).ID) {
fail("ассистент выключен в профиле")
return
}
base, key := s.imageConf()
if key == "" {
fail("не задан API-ключ сервиса картинок (Настройки › ИИ-ассистент › Генерация изображений)")
return
}
var in struct {
Mode string `json:"mode"`
Prompt string `json:"prompt"`
Model string `json:"model"`
Ratio string `json:"ratio"`
Enhance *bool `json:"enhance"` // the service improves the description; on unless false
ImageURL string `json:"image_url"`
Image struct {
MIME string `json:"mime"`
Data string `json:"data"`
} `json:"image"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in); err != nil {
fail("плохой запрос")
return
}
in.Prompt = strings.TrimSpace(in.Prompt)
if in.Prompt == "" {
fail("пустое описание картинки")
return
}
if r := []rune(in.Prompt); len(r) > 4000 {
in.Prompt = string(r[:4000])
}
var job struct {
JobID string `json:"job_id"`
Cost int `json:"cost"`
}
ctx := r.Context()
if in.Mode != "edit" {
if !imageModels[in.Model] {
in.Model = "fast"
}
if !imageRatios[in.Ratio] {
in.Ratio = "1:1"
}
enhance := in.Enhance == nil || *in.Enhance
b, _ := json.Marshal(map[string]any{"prompt": in.Prompt, "model": in.Model, "ratio": in.Ratio, "enhance": enhance})
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/generate", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if err := imageCall(ctx, req, key, &job); err != nil {
fail(err.Error())
return
}
} else {
mime, data, err := s.imageSource(ctx, base, in.Image.MIME, in.Image.Data, in.ImageURL)
if err != nil {
fail(err.Error())
return
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", `form-data; name="image"; filename="photo.`+imageExt[mime]+`"`)
h.Set("Content-Type", mime)
part, _ := mw.CreatePart(h)
_, _ = part.Write(data)
_ = mw.WriteField("prompt", in.Prompt)
_ = mw.Close()
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/edit", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if err := imageCall(ctx, req, key, &job); err != nil {
fail(err.Error())
return
}
}
if job.JobID == "" {
fail("сервис картинок не дал номер задачи")
return
}
writeJSON(w, map[string]any{"ok": true, "job_id": job.JobID, "cost": job.Cost})
}
// imageSource is the photo to edit: sent by the browser, or a picture made
// before, taken from the service's own address (and from no other).
func (s *Server) imageSource(ctx context.Context, base, mime, b64, picURL string) (string, []byte, error) {
if picURL != "" {
u, err := url.Parse(picURL)
bu, _ := url.Parse(base)
if err != nil || bu == nil || u.Host != bu.Host || (u.Scheme != "https" && u.Scheme != "http") {
return "", nil, errors.New("прежнюю картинку можно править только с сервиса картинок")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
resp, err := imageClient.Do(req)
if err != nil || resp.StatusCode/100 != 2 {
if resp != nil {
resp.Body.Close()
}
return "", nil, errors.New("прежняя картинка больше не открывается")
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, imageMaxBytes+1))
mime = strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
if imageExt[mime] == "" {
mime = "image/png"
}
return mime, data, nil
}
mime = strings.ToLower(mime)
if imageExt[mime] == "" {
return "", nil, errors.New("править можно png, jpg и webp")
}
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(data) == 0 {
return "", nil, errors.New("нет фото для правки: прикрепите его к сообщению")
}
if len(data) > imageMaxBytes {
return "", nil, errors.New("фото больше 20 МБ")
}
return mime, data, nil
}
// GET /api/panel/ai/image/{job}?wait=1..45 - the job's state, waiting up to
// wait seconds for it to end.
func (s *Server) handleAIImageJob(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
if !s.aiOn(s.currentAdmin(r).ID) {
fail("ассистент выключен в профиле")
return
}
base, key := s.imageConf()
id := r.PathValue("job")
if key == "" || !imageJobRe.MatchString(id) {
fail("нет такой задачи")
return
}
wait, _ := strconv.Atoi(r.URL.Query().Get("wait"))
if wait < 0 {
wait = 0
}
if wait > 45 {
wait = 45
}
req, _ := http.NewRequest(http.MethodGet, base+"/api/vertex/v1/jobs/"+id+"?wait="+strconv.Itoa(wait), nil)
var st struct {
Status string `json:"status"`
PhotoURL string `json:"photo_url"`
Cost int `json:"cost"`
Balance int `json:"balance"`
ErrorCode string `json:"error_code"`
Error string `json:"error"`
}
if err := imageCall(r.Context(), req, key, &st); err != nil {
fail(err.Error())
return
}
out := map[string]any{"ok": true, "status": st.Status}
switch st.Status {
case "done":
out["photo_url"], out["cost"], out["balance"] = st.PhotoURL, st.Cost, st.Balance
case "failed":
out["error"] = imageError(st.ErrorCode, st.Error)
}
writeJSON(w, out)
}
+305
View File
@@ -0,0 +1,305 @@
package server
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// fakeVertex is the image service: it records what it was asked and answers
// like the real one (a job to poll, a picture when done).
type fakeVertex struct {
srv *httptest.Server
mu sync.Mutex
auth []string
gen map[string]any
edit struct {
prompt string
image []byte
name string
}
polls int
failAs string // an error_code the job ends with
}
func newFakeVertex(t *testing.T) *fakeVertex {
f := &fakeVertex{}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.auth = append(f.auth, r.Header.Get("Authorization"))
// The pictures are open by their address, as at the real service.
if r.URL.Path == "/uploads/userphoto/ai/t.png" {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("\x89PNG-original"))
return
}
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("Authorization") != "Bearer vx_good" {
w.WriteHeader(401)
fmt.Fprint(w, `{"ok":false,"error_code":"unauthorized","error":"Неверный ключ"}`)
return
}
switch {
case r.URL.Path == "/api/vertex/v1/generate":
_ = json.NewDecoder(r.Body).Decode(&f.gen)
w.WriteHeader(202)
fmt.Fprint(w, `{"ok":true,"job_id":"job_gen1","status":"queued","cost":5}`)
case r.URL.Path == "/api/vertex/v1/edit":
if err := r.ParseMultipartForm(32 << 20); err != nil {
w.WriteHeader(400)
fmt.Fprint(w, `{"ok":false,"error_code":"invalid_image","error":"нет фото"}`)
return
}
f.edit.prompt = r.FormValue("prompt")
file, hdr, err := r.FormFile("image")
if err == nil {
f.edit.image, _ = io.ReadAll(file)
f.edit.name = hdr.Filename
}
w.WriteHeader(202)
fmt.Fprint(w, `{"ok":true,"job_id":"job_edit1","status":"queued","cost":2}`)
case strings.HasPrefix(r.URL.Path, "/api/vertex/v1/jobs/"):
f.polls++
if f.failAs != "" {
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"failed","error_code":%q,"error":"сбой"}`, f.failAs)
return
}
if f.polls < 2 {
fmt.Fprint(w, `{"ok":true,"job_id":"x","status":"running"}`)
return
}
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"done","photo_url":"%s/uploads/userphoto/ai/t.png","cost":5,"balance":42}`, f.srv.URL)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.srv.Close)
return f
}
// setImage points the panel at the fake service with a key.
func setImage(t *testing.T, s *Server, f *fakeVertex, key string) {
t.Helper()
_ = s.db.SetConfig(cfgImageURL, f.srv.URL)
_ = s.db.SetConfig(cfgImageKey, key)
}
func apiCall(t *testing.T, s *Server, c *http.Cookie, method, path, body string) string {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
// A picture from nothing: the prompt, model and ratio go to the service with
// the key; the job's progress and its picture come back, the key never.
func TestImageGenerate(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"cat in space","model":"artist","ratio":"3:4"}`)
if !strings.Contains(got, `"ok":true`) || !strings.Contains(got, `"job_id":"job_gen1"`) || strings.Contains(got, "vx_good") {
t.Fatalf("create: %s", got)
}
if f.gen["prompt"] != "cat in space" || f.gen["model"] != "artist" || f.gen["ratio"] != "3:4" || f.gen["enhance"] != true {
t.Fatalf("what the service got: %v", f.gen)
}
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", ""); !strings.Contains(got, `"status":"running"`) {
t.Fatalf("first poll: %s", got)
}
got = apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", "")
if !strings.Contains(got, `"status":"done"`) || !strings.Contains(got, "/uploads/userphoto/ai/t.png") || !strings.Contains(got, `"balance":42`) || strings.Contains(got, "vx_good") {
t.Fatalf("done: %s", got)
}
}
// Unknown models and ratios are not sent on: the defaults go instead.
func TestImageGenerateDefaults(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","model":"gpt","ratio":"7:1"}`)
if f.gen["model"] != "fast" || f.gen["ratio"] != "1:1" {
t.Fatalf("defaults: %v", f.gen)
}
}
// Editing sends the user's photo as a file with the instruction.
func TestImageEditFromAttachedPhoto(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
photo := []byte("\xff\xd8\xffJPEGDATA")
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "сделай закат",
"image": map[string]string{"mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(photo)}})
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b))
if !strings.Contains(got, `"job_id":"job_edit1"`) {
t.Fatalf("edit: %s", got)
}
if f.edit.prompt != "сделай закат" || !bytes.Equal(f.edit.image, photo) || !strings.HasSuffix(f.edit.name, ".jpg") {
t.Fatalf("what the service got: %q %q %q", f.edit.prompt, f.edit.image, f.edit.name)
}
// A format the service does not take is told so, not sent.
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image": map[string]string{"mime": "image/gif", "data": "R0lG"}})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "png") {
t.Fatalf("gif: %s", got)
}
}
// A picture made before can be edited again: fetched from the service's own
// address only - never from another.
func TestImageEditFromEarlierPicture(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "темнее", "image_url": f.srv.URL + "/uploads/userphoto/ai/t.png"})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":true`) {
t.Fatalf("by url: %s", got)
}
if !bytes.Equal(f.edit.image, []byte("\x89PNG-original")) || !strings.HasSuffix(f.edit.name, ".png") {
t.Fatalf("the picture sent: %q %q", f.edit.image, f.edit.name)
}
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image_url": "http://127.0.0.1:9/secret.png"})
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) {
t.Fatalf("a foreign address was fetched: %s", got)
}
}
// What goes wrong is told in words: no key, a wrong key, the service's own
// codes, a job that failed.
func TestImageErrors(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "ключ") {
t.Fatalf("no key: %s", got)
}
setImage(t, s, f, "vx_bad")
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "ключ") {
t.Fatalf("wrong key: %s", got)
}
setImage(t, s, f, "vx_good")
f.failAs = "insufficient_credits"
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1", ""); !strings.Contains(got, `"status":"failed"`) || !strings.Contains(got, "кредит") {
t.Fatalf("failed job: %s", got)
}
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/..%2Fme", ""); !strings.Contains(got, `"ok":false`) {
t.Fatalf("a job id out of shape: %s", got)
}
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":""}`); !strings.Contains(got, `"ok":false`) {
t.Fatalf("empty prompt: %s", got)
}
}
// The assistant off in the profile means no pictures either.
func TestImageNeedsAssistantOn(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "выключен") {
t.Fatalf("off: %s", got)
}
}
// The key is saved from Настройки, shown back to the admin, and the chat
// learns only that pictures are on.
func TestImageKeySetting(t *testing.T) {
s, c := newTestServer(t)
postAIModels(t, s, c, [][7]string{{"", "M", "gradio", "", "", "", ""}})
f := url.Values{"csrf": {"tok"}, "m_type": {"gradio"}, "ai_image_key": {"vx_secret"}}
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
s.Handler().ServeHTTP(httptest.NewRecorder(), req)
if _, k := s.imageConf(); k != "vx_secret" {
t.Fatalf("key not saved: %q", k)
}
if body := get(t, s, c, "/settings?frag=1").Body.String(); !strings.Contains(body, `value="vx_secret"`) {
t.Fatal("the settings page does not show the key")
}
state := get(t, s, c, "/api/panel/ai").Body.String()
if strings.Contains(state, "vx_secret") || !strings.Contains(state, `"image":true`) {
t.Fatalf("state: %s", state)
}
}
// With a key the model is told it can draw; without, it is not.
func TestImagePromptOnlyWithKey(t *testing.T) {
s, _ := newTestServer(t)
if strings.Contains(s.imagePrompt(), "```image") {
t.Fatal("told about pictures with no key")
}
_ = s.db.SetConfig(cfgImageKey, "vx_good")
p := s.imagePrompt()
for _, want := range []string{"```image\n", "```image edit", "fast", "artist", "0.2plus", "16:9"} {
if !strings.Contains(p, want) {
t.Fatalf("the prompt lacks %q:\n%s", want, p)
}
}
}
// The chat gives the model the image instructions when pictures are on.
func TestAIChatMentionsImageTool(t *testing.T) {
s, c := newTestServer(t)
var system string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
system, _ = b["messages"].([]any)[0].(map[string]any)["content"].(string)
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
}))
defer srv.Close()
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
ask := func() {
apiCall(t, s, c, "POST", "/api/panel/ai/chat", `{"message":"нарисуй кота","context":"x"}`)
}
ask()
if strings.Contains(system, "```image") {
t.Fatal("image tool offered with no key")
}
_ = s.db.SetConfig(cfgImageKey, "vx_good")
ask()
if !strings.Contains(system, "```image") {
t.Fatalf("image tool not offered with a key:\n%s", system)
}
}
// The description is improved by the service unless the model asks for it
// as it is.
func TestImageEnhanceSwitch(t *testing.T) {
s, c := newTestServer(t)
f := newFakeVertex(t)
setImage(t, s, f, "vx_good")
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`)
if f.gen["enhance"] != true {
t.Fatalf("by default: %v", f.gen)
}
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":false}`)
if f.gen["enhance"] != false {
t.Fatalf("switched off: %v", f.gen)
}
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":true}`)
if f.gen["enhance"] != true {
t.Fatalf("switched on: %v", f.gen)
}
{
p := s.imagePrompt()
_ = s.db.SetConfig(cfgImageKey, "vx_good")
if !strings.Contains(s.imagePrompt(), "noenhance") {
t.Fatalf("the model is not told of the switch:\n%s", p)
}
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
if !s.aiOn(a.ID) {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
+2
View File
@@ -266,6 +266,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
mux.Handle("POST /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
+163 -15
View File
@@ -110,8 +110,14 @@
// The models to choose from come with the state, again on every opening
// (an admin may have changed them).
let models = [];
// Pictures are on when the admin set the image service's key: the model then
// asks for them in its answers (```image blocks) - no switch of the user's.
let imageOn = false;
// The photos of the user's last message that had any, and the last picture
// made here: what an edit works on.
let sentImages = [], lastPic = '';
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
.then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
loadState();
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
@@ -237,6 +243,7 @@
setChat(id);
drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
sentImages = []; lastPic = '';
list.scrollTop = 0;
showJump(false);
hideFileError();
@@ -247,7 +254,7 @@
if (chatId !== id) return;
h.forEach(m => {
if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; if (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
});
if (!h.length) q('.ai-hello').hidden = false;
list.scrollTop = list.scrollHeight;
@@ -367,6 +374,7 @@
const p = r.data;
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
}
if (a.kind === 'image') return makeImage(a, signal);
// A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
@@ -421,13 +429,16 @@
// ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
// What the button of a card that has been done says (else «Готово»).
const DONE = {image: 'Создано'};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
// render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
@@ -467,16 +478,67 @@
// An action: a card with what and where on its title line, a small
// button on the right of it, the command or text below.
if (act) {
const where = head.slice(kind.length).trim();
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
// A picture's title says what it is (a new one or an edit); its first line
// (format, quality, file) is not shown - the words under it are.
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
// The text of a picture or a search folds: a line, or open - up to
// a few lines, then it scrolls. What the user left open stays so.
const fold = (kind === 'image' || kind === 'search') && code;
const open = fold && el._open && el._open.has(idx);
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
ICONS[kind] + '<b>' + title + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
'<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + LABEL[kind] + '</button></div>' +
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
});
body.innerHTML = html;
el.dataset.text = text;
// A text that fits one line has nothing to unfold: no arrow for it.
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
const p = c.querySelector('pre');
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
}));
}
// animateHeight: change() flips the card between folded and open; the
// text's box grows or shrinks to its new height instead of jumping. The
// text keeps its wrapped lines while it shrinks.
function animateHeight(pre, change) {
const h0 = pre.getBoundingClientRect().height;
change();
const still = document.body.classList.contains('no-motion') || matchMedia('(prefers-reduced-motion: reduce)').matches;
const h1 = pre.getBoundingClientRect().height;
if (still || Math.abs(h1 - h0) < 1) return;
const st = pre.style;
st.boxSizing = 'border-box'; st.overflow = 'hidden'; st.whiteSpace = 'pre-wrap'; st.textOverflow = 'clip';
st.height = h0 + 'px';
pre.getBoundingClientRect(); // the start is drawn before the end is set
st.transition = 'height .24s cubic-bezier(.3,.7,.3,1)';
st.height = h1 + 'px';
let t = 0;
const done = () => {
clearTimeout(t); pre.removeEventListener('transitionend', done);
st.height = st.transition = st.overflow = st.whiteSpace = st.textOverflow = st.boxSizing = '';
};
pre.addEventListener('transitionend', done);
t = setTimeout(done, 450);
}
const FOLD_CHEV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
list.addEventListener('click', ev => {
const t = ev.target.closest('.foldable .ai-fold, .foldable pre');
if (!t) return;
const card = t.closest('.foldable');
if (card.classList.contains('short') && !card.classList.contains('open')) return;
const msg = card.closest('.ai-msg'), pre = card.querySelector('pre');
let on = false;
animateHeight(pre, () => { on = card.classList.toggle('open'); });
const i = Number(card.dataset.n);
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
const b = card.querySelector('.ai-fold');
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
});
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
@@ -514,7 +576,14 @@
fileErrTimer = setTimeout(hideFileError, 5000);
}
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
// The file itself stays with its attachment (not sent anywhere): it is what
// a click on its name opens or saves.
async function makeAttachments(file, name = file.name) {
const made = await makeAttachmentsOf(file, name);
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
return made;
}
async function makeAttachmentsOf(file, name = file.name) {
const ext = fileExt(name), type = (file.type || '').toLowerCase();
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
@@ -550,7 +619,8 @@
}
}
const FILE_KIND = file => {
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
// Of an earlier talk only the name is kept: the kind comes from it.
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').toLowerCase();
if (mime.startsWith('image/')) return ['image', 'Изображение'];
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
if (mime.startsWith('video/')) return ['video', 'Видео'];
@@ -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"/>',
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
};
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
// is shown as plain text, so it cannot run anything; the rest is saved.
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
function openAttached(file) {
let blob = file.blob || null, show = false;
// The type of the file itself - not of its text (a Word file's attachment
// is its text, plain, but the file is not).
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
if (!blob && file.data) {
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
blob = new Blob([bytes], {type});
}
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
if (!blob) return;
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url;
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
function renderFileCards(host, files) {
if (!files.length) return;
const group = document.createElement('div'); group.className = 'ai-history-files';
@@ -575,7 +669,13 @@
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
const info = document.createElement('span'); info.className = 'ai-history-file-info';
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
// The name opens the file (or saves it) - when it is at hand: this
// page's own attachments; of an earlier talk only a text file's text.
const has = !!(file.blob || file.data || file.text);
const name = document.createElement(has ? 'button' : 'span');
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
const ext = fileExt(file.name).toUpperCase();
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
@@ -623,6 +723,51 @@
const el = add('result', '');
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text;
// A picture made: shown here, from its marker (kept with the talk, so
// it comes back with it) - and what the next edit works on.
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
if (!m) return;
lastPic = m[1];
const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img');
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
img.alt = 'Картинка'; img.src = m[1];
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img);
el.classList.add('pic');
el.insertBefore(link, el.firstChild);
}
// makeImage: a picture asked for in an answer - put at the image service
// through the server, asked about until it is done. What comes back is
// what the model is told: that it is made and shown (or why not).
async function makeImage(a, signal) {
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
if (a.edit) {
const want = (a.file || '').toLowerCase();
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
if (pick) body.image = {mime: pick.mime, data: pick.data};
else if (lastPic) body.image_url = lastPic;
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
}
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
if (!r.ok) return 'Ошибка картинки: ' + r.error;
let lost = 0;
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
if (!s.ok) {
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
return 'Ошибка картинки: ' + s.error;
}
if (s.status === 'done') {
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
}
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
}
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
}
list.addEventListener('click', async ev => {
const b = ev.target.closest('.ai-act');
@@ -632,7 +777,7 @@
b.disabled = true;
b.textContent = '…';
const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
});
@@ -766,21 +911,24 @@
// first action of each answer done (asking first for commands and writes)
// and its result sent back - until an answer asks for nothing.
async function talkTurn(text, files) {
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
if (imgs.length) sentImages = imgs;
let turn = await ask(text, null, files);
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on -
// and pictures, whenever they are on: the model asks for them itself.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) break;
const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await confirmAction(a)) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
continue;
}
if (b) { b.disabled = true; b.textContent = '…'; }
if (b) { b.disabled = true; b.textContent = a.kind === 'image' ? 'Рисую…' : '…'; }
const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
if (ac.signal.aborted) { showResult(res); break; }
turn = await ask(null, res);
}
+23 -1
View File
@@ -885,6 +885,10 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
.ai-history-file-info { display:grid; gap:2px; min-width:0; }
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
/* The model starting: three dots rising in turn. */
@@ -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-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
.ai-msg.result summary { cursor:pointer; }
/* A picture the model made: shown whole, the result folded small under it. */
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
.ai-msg.result.pic details { padding:0 4px; }
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
.ai-cap { font-size:12px; margin-bottom:4px; }
/* 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-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
.ai-kind { display:inline-flex; align-items: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 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; }
@@ -1100,6 +1110,18 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
lines and then it scrolls. */
.ai-code.foldable { --ai-fold-lines:6; }
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
.ai-code.foldable.short pre { cursor:default; }
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
.ai-fold:hover { color:var(--text); background:#1b222c; }
.ai-fold svg { transition:transform .2s ease; }
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
.ai-code.foldable.short .ai-fold { display:none; }
/* Аккаунт › Вид. */
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.acct-view summary { font-size:14px; color:var(--text); }
+15 -2
View File
@@ -46,12 +46,25 @@
// parseActions finds what an assistant's answer asks the panel to do:
// closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text).
// write [machine:]path (its body the file's text); search, fetch, and image
// (a picture to make or - with edit - to change; the first line may give
// its ratio, quality and the photo's file name).
function parseActions(text) {
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\n]*)\n([\s\S]*?)```/g;
for (let m; (m = re.exec(text));) {
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
if (kind === 'image') {
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
file = rest.trim();
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
continue;
}
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
const c = head.indexOf(':');
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
+17
View File
@@ -85,3 +85,20 @@ test('parseActions: fetch blocks', () => {
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
});
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
});
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
});
+16 -18
View File
@@ -10,22 +10,19 @@
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
}
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
/* The ufw switch hangs under the button, centred on it, level with the note. */
.frow .act { position:relative; }
.frow .act label.check {
position:absolute; top:100%; left:50%; transform:translateX(-50%);
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
color:var(--text); font-size:14px; cursor:pointer;
}
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
/* Under the fields: the note, and the ufw switch at the right, under the
button - on a line of its own when the room runs out, never over the note. */
.under { display:flex; align-items:flex-start; justify-content:space-between; gap:8px 16px; flex-wrap:wrap;
min-height:20px; margin:12px 0 0; font-size:13px; line-height:20px; }
.under #note { flex:1 1 240px; min-width:0; }
.under label.check { flex:none; display:inline-flex; align-items:center; gap:8px; height:20px; margin:0 0 0 auto;
color:var(--text); font-size:14px; cursor:pointer; white-space:nowrap; }
/* Phones: two fields a line, the label and the button each a line of their own. */
@media (max-width: 640px) {
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
.frow .fld.grow { flex:1 1 100%; }
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
.frow .act button { flex:1; }
.frow .act label.check { position:static; transform:none; margin:0; }
.under { margin-right:0; }
.frow .act { width:100%; }
.frow .act button { width:100%; }
}
</style>
<div class="wrap">
@@ -94,14 +91,15 @@
</div>
<div class="act">
<button type="submit">Создать</button>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw
</label>
{{end}}
</div>
</div>
<div class="under"><span class="muted" id="note"></span></div>
<div class="under"><span class="muted" id="note"></span>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw
</label>
{{end}}
</div>
</form>
</div>
@@ -202,6 +202,12 @@
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>