feat: pictures for the assistant - the models make and edit them by themselves through the Vertex API (a new picture, or the user's attached photo, or the last picture made), with an API key in Настройки and a switch for the prompt improver; folded descriptions of pictures and searches; the name of an attached file opens or saves it; a deleted chat is wiped from the database files, attachments included

This commit is contained in:
redtea committed 2026-10-18 12:12:54 +03:00
1 parent c72fcfebf3
commit dd1244a169
13 files changed
+925 -20

No files matched your search

+7
View File
@@ -53,6 +53,13 @@
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API (по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo. или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или - **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера. ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
+7 -1
View File
@@ -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_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner)
_, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner) _, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner)
s.db.wipeDeleted()
} }
// aiAppendTo keeps a message of owner's chat; the first question names it. // aiAppendTo keeps a message of owner's chat; the first question names it.
@@ -781,6 +782,7 @@ func (s *Server) aiKeepMsg(owner, chat int64, m aiMsg) {
now := time.Now().Unix() now := time.Now().Unix()
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS) _, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep) _, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
s.db.wipeDeleted()
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat) _, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
if role == "user" { if role == "user" {
t := strings.Join(strings.Fields(content), " ") t := strings.Join(strings.Fields(content), " ")
@@ -950,6 +952,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") + system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу." "): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
} }
system += s.imagePrompt()
if in.Agent { if in.Agent {
system += aiAgent system += aiAgent
} }
@@ -1152,7 +1155,8 @@ func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
for _, m := range s.aiModels() { for _, m := range s.aiModels() {
ms = append(ms, pick{m.ID, m.label(), m.Short}) ms = append(ms, pick{m.ID, m.label(), m.Short})
} }
writeJSON(w, map[string]any{"on": on != "0", "models": ms}) _, imgKey := s.imageConf()
writeJSON(w, map[string]any{"on": on != "0", "models": ms, "image": imgKey != ""})
} }
// POST /api/panel/ai/toggle - {on}: the user's own switch. // POST /api/panel/ai/toggle - {on}: the user's own switch.
@@ -1220,6 +1224,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
_ = s.db.SetConfig(cfgSearchEngine, eng) _ = s.db.SetConfig(cfgSearchEngine, eng)
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url"))) _ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key"))) _ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
// The key of the image service («Генерация изображений»).
_ = s.db.SetConfig(cfgImageKey, strings.TrimSpace(r.FormValue("ai_image_key")))
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther) http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
} }
+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) { func OpenDB(path string) (*DB, error) {
// _pragma busy_timeout avoids "database is locked" under the concurrent // _pragma busy_timeout avoids "database is locked" under the concurrent
// long-poll load; foreign_keys keeps the schema honest. // long-poll load; foreign_keys keeps the schema honest.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path) dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
sdb, err := sql.Open("sqlite", dsn) sdb, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -754,3 +754,15 @@ func boolInt(b bool) int {
} }
return 0 return 0
} }
// wipeDeleted makes what was deleted leave the database's files: with
// secure_delete the freed pages are overwritten, and the write-ahead log,
// which still holds the rows as they were written, is emptied (SQLite only;
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
// among that.
func (db *DB) wipeDeleted() {
if db.sql.pg {
return
}
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
+1
View File
@@ -445,6 +445,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"ServerVersion": Version, "ServerVersion": Version,
"AIModels": s.aiModels(), "AIModels": s.aiModels(),
"Search": s.searchConf(), "Search": s.searchConf(),
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
"DBNow": dbNow, "DBNow": dbNow,
"DBChosen": dbChosen, "DBChosen": dbChosen,
"UfwInstalled": ufwInstalled, "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)
}
}
}
+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/note", s.requireAdmin(s.handleAINote))
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch)) mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch)) mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
mux.Handle("POST /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB)) mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart)) mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
+138 -15
View File
@@ -110,8 +110,14 @@
// The models to choose from come with the state, again on every opening // The models to choose from come with the state, again on every opening
// (an admin may have changed them). // (an admin may have changed them).
let models = []; let models = [];
// Pictures are on when the admin set the image service's key: the model then
// asks for them in its answers (```image blocks) - no switch of the user's.
let imageOn = false;
// The photos of the user's last message that had any, and the last picture
// made here: what an edit works on.
let sentImages = [], lastPic = '';
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()) const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {}); .then(d => { setOn(!!d.on); models = d.models || []; imageOn = !!d.image; drawModels(); }).catch(() => {});
loadState(); loadState();
document.addEventListener('lpra:ai', ev => setOn(ev.detail)); document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); }; fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
@@ -237,6 +243,7 @@
setChat(id); setChat(id);
drawChats(); drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove()); list.querySelectorAll('.ai-msg').forEach(m => m.remove());
sentImages = []; lastPic = '';
list.scrollTop = 0; list.scrollTop = 0;
showJump(false); showJump(false);
hideFileError(); hideFileError();
@@ -247,7 +254,7 @@
if (chatId !== id) return; if (chatId !== id) return;
h.forEach(m => { h.forEach(m => {
if (m.role === 'result') showResult(m.content); if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); } else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; if (DONE[b.dataset.kind]) b.textContent = DONE[b.dataset.kind]; }); }
}); });
if (!h.length) q('.ai-hello').hidden = false; if (!h.length) q('.ai-hello').hidden = false;
list.scrollTop = list.scrollHeight; list.scrollTop = list.scrollHeight;
@@ -367,6 +374,7 @@
const p = r.data; const p = r.data;
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text; return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
} }
if (a.kind === 'image') return makeImage(a, signal);
// A web search: through the server, only with «Поиск» on. // A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') { if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»'; if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
@@ -421,13 +429,16 @@
// ---------- messages ---------- // ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept. // stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, ''); const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'}; const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница', image: 'генерация картинки'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>'; const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'), const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'), ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'), search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')}; fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'}; image: IC('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>')};
// What the button of a card that has been done says (else «Готово»).
const DONE = {image: 'Создано'};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть', image: 'Создать'};
// render: text with ``` blocks; action blocks get their button. // render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with // render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The // Markdown; ``` blocks apart, action blocks with their button. The
@@ -467,16 +478,42 @@
// An action: a card with what and where on its title line, a small // An action: a card with what and where on its title line, a small
// button on the right of it, the command or text below. // button on the right of it, the command or text below.
if (act) { if (act) {
const where = head.slice(kind.length).trim(); // A picture's title says what it is (a new one or an edit); its first line
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' + // (format, quality, file) is not shown - the words under it are.
const where = kind === 'image' ? '' : head.slice(kind.length).trim(), idx = n++;
const title = kind === 'image' && /^image\s+edit(\s|$)/.test(head) ? 'редактирование картинки' : KIND[kind];
// The text of a picture or a search folds: a line, or open - up to
// a few lines, then it scrolls. What the user left open stays so.
const fold = (kind === 'image' || kind === 'search') && code;
const open = fold && el._open && el._open.has(idx);
html += '<div class="ai-code ai-action' + (fold ? ' foldable' + (open ? ' open' : '') : '') + '" data-n="' + idx + '"><div class="ai-code-h"><span class="ai-kind">' +
(fold ? '<button type="button" class="ai-fold" aria-expanded="' + !!open + '" title="' + (open ? 'Свернуть' : 'Развернуть') + '">' + FOLD_CHEV + '</button>' : '') +
ICONS[kind] + '<b>' + title + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' + (where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' + '<button type="button" class="ai-act" data-i="' + idx + '" data-kind="' + kind + '">' + LABEL[kind] + '</button></div>' +
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>'; (code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>'; } else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
}); });
body.innerHTML = html; body.innerHTML = html;
el.dataset.text = text; el.dataset.text = text;
// A text that fits one line has nothing to unfold: no arrow for it.
requestAnimationFrame(() => body.querySelectorAll('.foldable:not(.open)').forEach(c => {
const p = c.querySelector('pre');
c.classList.toggle('short', p.scrollWidth <= p.clientWidth + 1 && !/\n/.test(p.textContent));
}));
} }
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'), on = card.classList.toggle('open');
const i = Number(card.dataset.n);
(msg._open = msg._open || new Set())[on ? 'add' : 'delete'](i);
const b = card.querySelector('.ai-fold');
b.setAttribute('aria-expanded', String(on)); b.title = on ? 'Свернуть' : 'Развернуть';
});
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || ''; const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/; const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
const officeExt = /^(docx|xlsx|ods|odt|doc)$/; const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
@@ -514,7 +551,14 @@
fileErrTimer = setTimeout(hideFileError, 5000); fileErrTimer = setTimeout(hideFileError, 5000);
} }
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); } function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
// The file itself stays with its attachment (not sent anywhere): it is what
// a click on its name opens or saves.
async function makeAttachments(file, name = file.name) { async function makeAttachments(file, name = file.name) {
const made = await makeAttachmentsOf(file, name);
made.forEach(a => Object.defineProperty(a, 'blob', {value: file}));
return made;
}
async function makeAttachmentsOf(file, name = file.name) {
const ext = fileExt(name), type = (file.type || '').toLowerCase(); const ext = fileExt(name), type = (file.type || '').toLowerCase();
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл'); if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) { if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
@@ -550,7 +594,8 @@
} }
} }
const FILE_KIND = file => { const FILE_KIND = file => {
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase(); // Of an earlier talk only the name is kept: the kind comes from it.
const ext = fileExt(file.name), mime = (file.mime || MEDIA_MIME[ext] || '').toLowerCase();
if (mime.startsWith('image/')) return ['image', 'Изображение']; if (mime.startsWith('image/')) return ['image', 'Изображение'];
if (mime.startsWith('audio/')) return ['audio', 'Аудио']; if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
if (mime.startsWith('video/')) return ['video', 'Видео']; if (mime.startsWith('video/')) return ['video', 'Видео'];
@@ -566,6 +611,30 @@
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>', document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>' text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
}; };
// What a browser shows by itself: raster pictures, sound, video, PDF. Text
// is shown as plain text, so it cannot run anything; the rest is saved.
const SHOWN = /^(image\/(png|jpe?g|gif|webp|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf)$/;
function openAttached(file) {
let blob = file.blob || null, show = false;
// The type of the file itself - not of its text (a Word file's attachment
// is its text, plain, but the file is not).
const ext = fileExt(file.name), own = MEDIA_MIME[ext] || (ext === 'pdf' ? 'application/pdf' : '');
const type = ((blob ? blob.type || own : file.mime || own) || '').toLowerCase();
if (!blob && file.data) {
const bin = atob(file.data), bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
blob = new Blob([bytes], {type});
}
if (blob && SHOWN.test(type)) { blob = new Blob([blob], {type}); show = true; }
else if (blob && (textExt.test(ext) || type.startsWith('text/'))) { blob = new Blob([blob], {type: 'text/plain;charset=utf-8'}); show = true; }
else if (!blob && file.text) { blob = new Blob([file.text], {type: 'text/plain;charset=utf-8'}); show = true; }
if (!blob) return;
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url;
if (show) { a.target = '_blank'; a.rel = 'noopener'; } else a.download = file.name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
function renderFileCards(host, files) { function renderFileCards(host, files) {
if (!files.length) return; if (!files.length) return;
const group = document.createElement('div'); group.className = 'ai-history-files'; const group = document.createElement('div'); group.className = 'ai-history-files';
@@ -575,7 +644,13 @@
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon'; const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>'; icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
const info = document.createElement('span'); info.className = 'ai-history-file-info'; const info = document.createElement('span'); info.className = 'ai-history-file-info';
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name; // The name opens the file (or saves it) - when it is at hand: this
// page's own attachments; of an earlier talk only a text file's text.
const has = !!(file.blob || file.data || file.text);
const name = document.createElement(has ? 'button' : 'span');
name.className = 'ai-history-file-name' + (has ? ' link' : ''); name.textContent = file.name;
name.title = has ? file.name : file.name + ' - файл недоступен после перезагрузки страницы';
if (has) { name.type = 'button'; name.onclick = () => openAttached(file); }
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta'; const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
const ext = fileExt(file.name).toUpperCase(); const ext = fileExt(file.name).toUpperCase();
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : ''); meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
@@ -623,6 +698,51 @@
const el = add('result', ''); const el = add('result', '');
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>'; el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text; el.querySelector('pre').textContent = text;
// A picture made: shown here, from its marker (kept with the talk, so
// it comes back with it) - and what the next edit works on.
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
if (!m) return;
lastPic = m[1];
const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img');
link.className = 'ai-pic'; link.href = m[1]; link.target = '_blank'; link.rel = 'noopener'; link.title = 'Открыть картинку';
img.alt = 'Картинка'; img.src = m[1];
img.onload = () => { if (follow) list.scrollTop = list.scrollHeight; updateJump(); };
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img);
el.classList.add('pic');
el.insertBefore(link, el.firstChild);
}
// makeImage: a picture asked for in an answer - put at the image service
// through the server, asked about until it is done. What comes back is
// what the model is told: that it is made and shown (or why not).
async function makeImage(a, signal) {
if (!imageOn) return 'Ошибка: генерация изображений не настроена - нет API-ключа в Настройки';
const post = (url, opt) => fetch(url, Object.assign({credentials: 'same-origin'}, opt)).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи с панелью'}));
const body = {mode: a.edit ? 'edit' : 'generate', prompt: a.body, model: a.model, ratio: a.ratio, enhance: a.enhance !== false};
if (a.edit) {
const want = (a.file || '').toLowerCase();
const pick = (want && sentImages.find(f => f.name.toLowerCase() === want)) || sentImages[sentImages.length - 1];
if (pick) body.image = {mime: pick.mime, data: pick.data};
else if (lastPic) body.image_url = lastPic;
else return 'Ошибка: нет фото для правки - пользователь не прикрепил его к сообщению, и своих картинок в этом чате ещё нет';
}
const r = await post('/api/panel/ai/image', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
if (!r.ok) return 'Ошибка картинки: ' + r.error;
let lost = 0;
for (const t0 = Date.now(); !(signal && signal.aborted) && Date.now() - t0 < 8 * 60000;) {
const s = await post('/api/panel/ai/image/' + encodeURIComponent(r.job_id) + '?wait=40', {cache: 'no-store'});
if (!s.ok) {
if (s.error === 'нет связи с панелью' && ++lost < 4) { await sleep(2000); continue; }
return 'Ошибка картинки: ' + s.error;
}
if (s.status === 'done') {
return 'Картинка готова и уже показана пользователю [картинка: ' + s.photo_url + ']. Ссылку в ответ не вставляй: коротко скажи, что получилось' +
(s.cost ? ' (списано кредитов: ' + s.cost + (s.balance != null ? ', осталось ' + s.balance : '') + ')' : '') + '.';
}
if (s.status === 'failed') return 'Ошибка картинки: ' + s.error;
}
return signal && signal.aborted ? 'Ошибка: ожидание картинки прервано' : 'Ошибка: картинка не получилась за 8 минут';
} }
list.addEventListener('click', async ev => { list.addEventListener('click', async ev => {
const b = ev.target.closest('.ai-act'); const b = ev.target.closest('.ai-act');
@@ -632,7 +752,7 @@
b.disabled = true; b.disabled = true;
b.textContent = '…'; b.textContent = '…';
const res = await perform(a); const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
showResult(res); showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})}); fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
}); });
@@ -766,21 +886,24 @@
// first action of each answer done (asking first for commands and writes) // first action of each answer done (asking first for commands and writes)
// and its result sent back - until an answer asks for nothing. // and its result sent back - until an answer asks for nothing.
async function talkTurn(text, files) { async function talkTurn(text, files) {
const imgs = (files || []).filter(f => f.data && /^image\//.test(f.mime || ''));
if (imgs.length) sentImages = imgs;
let turn = await ask(text, null, files); let turn = await ask(text, null, files);
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) { for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0]; const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break; if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on. // Without «Агент» only searches go on by themselves, with «Поиск» on -
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break; // and pictures, whenever they are on: the model asks for them itself.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn()) && !(a.kind === 'image' && imageOn)) break;
const b = turn.el.querySelector('.ai-act'); const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await confirmAction(a)) { if (needsYes(a) && !await confirmAction(a)) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; } if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.'); turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
continue; continue;
} }
if (b) { b.disabled = true; b.textContent = '…'; } if (b) { b.disabled = true; b.textContent = a.kind === 'image' ? 'Рисую…' : '…'; }
const res = await perform(a, ac.signal); const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово'; if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : DONE[a.kind] || 'Готово';
if (ac.signal.aborted) { showResult(res); break; } if (ac.signal.aborted) { showResult(res); break; }
turn = await ask(null, res); turn = await ask(null, res);
} }
+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.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
.ai-history-file-info { display:grid; gap:2px; min-width:0; } .ai-history-file-info { display:grid; gap:2px; min-width:0; }
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; } .ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
.ai-history-file-meta { color:var(--muted); font-size:10.5px; } .ai-history-file-meta { color:var(--muted); font-size:10.5px; }
/* The model starting: three dots rising in turn. */ /* The model starting: three dots rising in turn. */
@@ -954,6 +958,12 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
.ai-agent input { width:auto; margin:0; } .ai-agent input { width:auto; margin:0; }
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); } .ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
.ai-msg.result summary { cursor:pointer; } .ai-msg.result summary { cursor:pointer; }
/* A picture the model made: shown whole, the result folded small under it. */
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
.ai-msg.result.pic details { padding:0 4px; }
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; } .ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
.ai-cap { font-size:12px; margin-bottom:4px; } .ai-cap { font-size:12px; margin-bottom:4px; }
/* Modes as pills: on - lit in the accent, off - a quiet outline. */ /* Modes as pills: on - lit in the accent, off - a quiet outline. */
@@ -1091,7 +1101,7 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; } .ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; } .ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; } .ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
.ai-kind { display:inline-flex; align-items:baseline; gap:6px; min-width:0; color:var(--muted); } .ai-kind { display:inline-flex; align-items:center; gap:6px; min-width:0; color:var(--muted); }
.ai-kind svg { align-self:center; flex:none; color:var(--accent); } .ai-kind svg { align-self:center; flex:none; color:var(--accent); }
.ai-kind b { font-weight:500; color:var(--text); flex:none; } .ai-kind b { font-weight:500; color:var(--text); flex:none; }
.ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; } .ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; }
@@ -1100,6 +1110,18 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); } .ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; } .ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; } .ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
lines and then it scrolls. */
.ai-code.foldable { --ai-fold-lines:6; }
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
.ai-code.foldable.short pre { cursor:default; }
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
.ai-fold:hover { color:var(--text); background:#1b222c; }
.ai-fold svg { transition:transform .2s ease; }
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
.ai-code.foldable.short .ai-fold { display:none; }
/* Аккаунт › Вид. */ /* Аккаунт › Вид. */
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); } .acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.acct-view summary { font-size:14px; color:var(--text); } .acct-view summary { font-size:14px; color:var(--text); }
+15 -2
View File
@@ -46,12 +46,25 @@
// parseActions finds what an assistant's answer asks the panel to do: // parseActions finds what an assistant's answer asks the panel to do:
// closed ``` blocks headed run [machine], read|ls [machine:]path or // closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text). // write [machine:]path (its body the file's text); search, fetch, and image
// (a picture to make or - with edit - to change; the first line may give
// its ratio, quality and the photo's file name).
function parseActions(text) { function parseActions(text) {
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g; const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\n]*)\n([\s\S]*?)```/g;
for (let m; (m = re.exec(text));) { for (let m; (m = re.exec(text));) {
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, ''); const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; } if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
if (kind === 'image') {
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
file = rest.trim();
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
continue;
}
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; } if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
const c = head.indexOf(':'); const c = head.indexOf(':');
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : ''; const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
+17
View File
@@ -85,3 +85,20 @@ test('parseActions: fetch blocks', () => {
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'), assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]); [{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
}); });
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
});
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
});
@@ -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"> <label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label> <button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details> </div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<div><button type="submit">Сохранить</button></div> <div><button type="submit">Сохранить</button></div>
</form> </form>
</div> </div>