From dd1244a169dd975f3616d32a9f8b0fe957078a2c Mon Sep 17 00:00:00 2001 From: iRedTea Date: Sun, 18 Oct 2026 12:12:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20pictures=20for=20the=20assistant=20-=20?= =?UTF-8?q?the=20models=20make=20and=20edit=20them=20by=20themselves=20thr?= =?UTF-8?q?ough=20the=20Vertex=20API=20(a=20new=20picture,=20or=20the=20us?= =?UTF-8?q?er's=20attached=20photo,=20or=20the=20last=20picture=20made),?= =?UTF-8?q?=20with=20an=20API=20key=20in=20=D0=9D=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20and=20a=20switch=20for=20the=20promp?= =?UTF-8?q?t=20improver;=20folded=20descriptions=20of=20pictures=20and=20s?= =?UTF-8?q?earches;=20the=20name=20of=20an=20attached=20file=20opens=20or?= =?UTF-8?q?=20saves=20it;=20a=20deleted=20chat=20is=20wiped=20from=20the?= =?UTF-8?q?=20database=20files,=20attachments=20included?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + internal/server/ai.go | 8 +- internal/server/chatdel_test.go | 89 ++++++ internal/server/db.go | 14 +- internal/server/handlers_panel.go | 1 + internal/server/image.go | 302 +++++++++++++++++++ internal/server/image_test.go | 305 ++++++++++++++++++++ internal/server/server.go | 2 + internal/server/web/static/ai.js | 153 +++++++++- internal/server/web/static/app.css | 24 +- internal/server/web/static/wsutil.js | 17 +- internal/server/web/static/wsutil.test.js | 17 ++ internal/server/web/templates/settings.html | 6 + 13 files changed, 925 insertions(+), 20 deletions(-) create mode 100644 internal/server/chatdel_test.go create mode 100644 internal/server/image.go create mode 100644 internal/server/image_test.go diff --git a/README.md b/README.md index 2d77146..ec62a79 100644 --- a/README.md +++ b/README.md @@ -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` рядом с diff --git a/internal/server/ai.go b/internal/server/ai.go index 755b512..f85e728 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -763,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. @@ -781,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), " ") @@ -950,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 } @@ -1152,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. @@ -1220,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) } diff --git a/internal/server/chatdel_test.go b/internal/server/chatdel_test.go new file mode 100644 index 0000000..8805919 --- /dev/null +++ b/internal/server/chatdel_test.go @@ -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)) + } + } +} diff --git a/internal/server/db.go b/internal/server/db.go index 0b911b8..926a12b 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -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)`) +} diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 367d8c6..bbb5bdf 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -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, diff --git a/internal/server/image.go b/internal/server/image.go new file mode 100644 index 0000000..130ec95 --- /dev/null +++ b/internal/server/image.go @@ -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) +} diff --git a/internal/server/image_test.go b/internal/server/image_test.go new file mode 100644 index 0000000..060b6e5 --- /dev/null +++ b/internal/server/image_test.go @@ -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) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 1bb57a4..d03ac01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 44f00d0..f64b7f3 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -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*(?:|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); - const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'}; + const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'}; const IC = d => '' + d + ''; const ICONS = {run: IC(''), read: IC(''), ls: IC(''), write: IC(''), search: IC(''), - fetch: IC('')}; - const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'}; + fetch: IC(''), + image: IC('')}; + // 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,42 @@ // 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 += '
' + ICONS[kind] + '' + KIND[kind] + '' + + // 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 += '
' + + (fold ? '' : '') + + ICONS[kind] + '' + title + '' + (where ? '' + esc(where) + '' : '') + '' + - '
' + + '
' + (code ? '
' + esc(code) + '
' : '') + '
'; } else html += '
' + esc(code) + '
'; }); 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)); + })); } + const FOLD_CHEV = ''; + 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'), 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 +551,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 +594,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 +611,30 @@ document: '', text: '' }; + // 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 +644,13 @@ const icon = document.createElement('span'); icon.className = 'ai-history-file-icon'; icon.innerHTML = '' + FILE_ICONS[kind] + ''; 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 +698,51 @@ const el = add('result', ''); el.innerHTML = '
Результат
'; 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 +752,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 +886,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); } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index d8eae77..a2747d9 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -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); } diff --git a/internal/server/web/static/wsutil.js b/internal/server/web/static/wsutil.js index c133bbe..9c016b8 100644 --- a/internal/server/web/static/wsutil.js +++ b/internal/server/web/static/wsutil.js @@ -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) : ''; diff --git a/internal/server/web/static/wsutil.test.js b/internal/server/web/static/wsutil.test.js index c6a5356..688a731 100644 --- a/internal/server/web/static/wsutil.test.js +++ b/internal/server/web/static/wsutil.test.js @@ -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); +}); diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 20c400c..ed93594 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -202,6 +202,12 @@
+