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) }