From bea2c6121619cd8e5e07631337f8364aaaa56e7d Mon Sep 17 00:00:00 2001 From: Coder Date: Wed, 4 Nov 2026 19:01:50 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20xAI=20Grok=20Im?= =?UTF-8?q?agine=20API=20(/v1/images/edits)=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20Codex=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EDIT_ENGINE=grok по умолчанию, XAI_API_KEY/XAI_BASE_URL/XAI_IMAGE_MODEL/XAI_TIMEOUT_MS; Codex остаётся для подсказок и как fallback (EDIT_ENGINE=codex). --- .gitea/workflows/deploy.yml | 5 +- README.md | 7 +- deploy/docker-compose.yml | 8 ++ docker-compose.yml | 2 + src/config.js | 17 ++++ src/core/engines/grok-edit-engine.js | 145 +++++++++++++++++++++++++++ src/core/xai/client.js | 87 ++++++++++++++++ src/server.js | 17 +++- test/grok-edit.test.js | 60 +++++++++++ 9 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 src/core/engines/grok-edit-engine.js create mode 100644 src/core/xai/client.js create mode 100644 test/grok-edit.test.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 90bb4e3..70948be 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -24,6 +24,8 @@ jobs: # Секреты Gitea: Settings → Actions → Secrets → APP_USER / APP_PASSWORD APP_USER: ${{ secrets.APP_USER }} APP_PASSWORD: ${{ secrets.APP_PASSWORD }} + # Ключ xAI для редактирования через Grok Imagine (секрет XAI_API_KEY) + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} STACK_NAME: photo-editor # Если не задано — берётся первый endpoint из /api/endpoints ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }} @@ -42,7 +44,8 @@ jobs: ENV_JSON=$(jq -nc \ --arg u "${APP_USER:-}" \ --arg p "${APP_PASSWORD:-}" \ - '[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p}]') + --arg x "${XAI_API_KEY:-}" \ + '[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p},{name:"XAI_API_KEY",value:$x}]') # Определяем endpoint (окружение Docker), если не задан вручную if [ -z "${ENDPOINT_ID:-}" ]; then diff --git a/README.md b/README.md index 7d2650a..06f70e8 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Codex CLI) и увеличивает разрешение через домаш ## Как это работает - **История версий**: каждая загрузка и результат (редактирование или апскейл) сохраняются в `data/history/` как отдельная версия. Доступно интерактивное дерево версий с ветвлением от исходника, каскадным удалением и свёрткой групп. Хранятся последние 50 версий (настраивается через `HISTORY_LIMIT`). -- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex CLI (`codex exec`). Агент редактирует изображение и сохраняет результат в JPEG. +- **Редактирование**: сервер отправляет выбранную версию (и референс, если задан) с промптом в xAI Grok Imagine — `POST https://api.x.ai/v1/images/edits`, модель `grok-imagine-image-2.0`, изображения как base64 data URI, ответ `b64_json` (JPEG). Документация: https://docs.x.ai/docs/guides/image-generation. Прежний движок Codex CLI доступен через `EDIT_ENGINE=codex`; подсказки промптов по-прежнему генерирует Codex. - **Увеличение разрешения**: 4× upscale через ComfyUI моделью `RealESRGAN_x4plus.safetensors` (или `4x_UltraSharp`). - **Редактирование с референсом и областью**: можно задать референс — файл или версию из истории. На превью референса выделяется прямоугольник области; координаты передаются в промпт Codex. - **Подсказки промптов**: мгновенные варианты из недавней истории и генерация новых вариантов через Codex. @@ -71,6 +71,11 @@ Codex CLI) и увеличивает разрешение через домаш | `MAX_QUEUED_JOBS` | number | `5` | Максимальный размер очереди на группу | | `SSE_ENABLED` | bool | `true` | Включение Server-Sent Events | | `TRUST_PROXY` | bool | `false` | Доверие заголовкам прокси (`X-Forwarded-For` для rate-limiting) | +| `EDIT_ENGINE` | string | `grok` | Движок редактирования: `grok` (xAI Imagine API) или `codex` (Codex CLI) | +| `XAI_API_KEY` | string | — | API-ключ xAI (обязателен для `EDIT_ENGINE=grok`; в логи не выводится) | +| `XAI_BASE_URL` | string | `https://api.x.ai/v1` | Базовый URL xAI API | +| `XAI_IMAGE_MODEL` | string | `grok-imagine-image-2.0` | Модель редактирования изображений | +| `XAI_TIMEOUT_MS` | number | `180000` (3 мин) | Таймаут запроса к xAI | --- diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6694f22..cbe4e4f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -33,6 +33,14 @@ services: # (печатается в лог контейнера). - APP_USER=${APP_USER} - APP_PASSWORD=${APP_PASSWORD} + # Редактирование фото через xAI Grok Imagine (POST /v1/images/edits). + # Ключ задаётся в переменных стека Portainer / секрете XAI_API_KEY. + - XAI_API_KEY=${XAI_API_KEY} + - EDIT_ENGINE=grok + # - XAI_IMAGE_MODEL=grok-imagine-image-2.0 + # Node fetch не использует HTTP(S)_PROXY без этого флага; если прямой + # доступ к api.x.ai блокируется — раскомментируйте (Node >= 22.21): + # - NODE_USE_ENV_PROXY=1 # - OPENAI_API_KEY=${OPENAI_API_KEY} restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 39911b1..fc5a2a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,4 +13,6 @@ services: environment: # Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN) - COMFY_URL=http://192.168.31.240:8188 + # Ключ xAI для редактирования через Grok Imagine + - XAI_API_KEY=${XAI_API_KEY} restart: unless-stopped diff --git a/src/config.js b/src/config.js index 4a654f7..936d640 100644 --- a/src/config.js +++ b/src/config.js @@ -152,6 +152,16 @@ function loadConfig(env = process.env) { const codexModel = env.CODEX_MODEL || null; const codexReasoningEffort = env.CODEX_REASONING_EFFORT || null; + // xAI Grok Imagine (редактирование изображений) + const editEngine = (env.EDIT_ENGINE || "grok").toLowerCase(); + if (!["grok", "codex"].includes(editEngine)) { + issues.push(`EDIT_ENGINE должен быть grok или codex, получено: "${env.EDIT_ENGINE}"`); + } + const xaiApiKey = env.XAI_API_KEY || ""; + const xaiBaseUrl = env.XAI_BASE_URL || "https://api.x.ai/v1"; + const xaiImageModel = env.XAI_IMAGE_MODEL || "grok-imagine-image-2.0"; + const xaiTimeoutMs = parseNumber(env.XAI_TIMEOUT_MS, 180000, "XAI_TIMEOUT_MS", issues, 1000); + if (issues.length > 0) { throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues); } @@ -187,11 +197,18 @@ function loadConfig(env = process.env) { MAX_QUEUED_JOBS: maxQueuedJobs, SSE_ENABLED: sseEnabled, TRUST_PROXY: trustProxy, + EDIT_ENGINE: editEngine, + XAI_API_KEY: xaiApiKey, + XAI_BASE_URL: xaiBaseUrl, + XAI_IMAGE_MODEL: xaiImageModel, + XAI_TIMEOUT_MS: xaiTimeoutMs, getSanitized() { const copy = { ...this }; delete copy.APP_PASSWORD; copy.APP_PASSWORD_SET = Boolean(this.APP_PASSWORD); + delete copy.XAI_API_KEY; + copy.XAI_API_KEY_SET = Boolean(this.XAI_API_KEY); return copy; }, }; diff --git a/src/core/engines/grok-edit-engine.js b/src/core/engines/grok-edit-engine.js new file mode 100644 index 0000000..bddbf86 --- /dev/null +++ b/src/core/engines/grok-edit-engine.js @@ -0,0 +1,145 @@ +"use strict"; + +const { downscaleImage } = require("../images/resize"); +const { getImageDimensions } = require("../images/dimensions"); +const { scaleRegion } = require("../codex/prompts"); +const { truncate } = require("../../logger"); +const { AppError } = require("../../errors"); + +function mimeFromExt(ext) { + return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg"; +} +function extFromMime(m) { + return m && m.includes("png") ? "png" : m && m.includes("webp") ? "webp" : "jpg"; +} + +/** Промпт для Grok Imagine: исходное фото — первое изображение, референс — второе. */ +function buildGrokEditPrompt({ prompt, hasReference = false, region = null }) { + const lines = [`Edit the first image: ${prompt}.`]; + if (hasReference) { + lines.push( + "The second image is the same scene with a red rectangle marking the area to edit; it is only a guide — do not copy the red rectangle into the result." + ); + if (region) { + lines.push(`Rectangle in pixels: x=${region.x}, y=${region.y}, width=${region.w}, height=${region.h}.`); + } + lines.push("Apply the change only inside that area and keep everything else unchanged."); + } + lines.push("Keep the original composition, subjects, faces, proportions and framing; do not redraw the photo from scratch."); + return lines.join(" "); +} + +/** + * Движок редактирования через xAI Grok Imagine (POST /v1/images/edits). + * @param {import('./engine').EngineContext & {xaiClient: import('../xai/client').XaiImageClient}} ctx + */ +function createGrokEditEngine(ctx) { + const { config, logger, history, xaiClient } = ctx; + + return { + async run(job) { + const { sourceId, inputBuffer, inputMimetype, prompt, referenceId, referenceBuffer, referenceMimetype, region } = + job.payload || {}; + + if (!prompt || !String(prompt).trim()) { + throw new AppError(400, "Промпт обязателен для редактирования."); + } + + let rawBuffer; + if (sourceId) { + rawBuffer = (await history.getVersionFile(sourceId)).buffer; + } else if (inputBuffer) { + rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer); + } else { + throw new AppError(400, "Исходная фотография не найдена."); + } + + const maxDim = config.EDIT_MAX_DIMENSION || 0; + const scaled = await downscaleImage(rawBuffer, maxDim); + const factor = scaled.factor || 1; + const images = [{ buffer: scaled.buffer, mimetype: mimeFromExt(extFromMime(inputMimetype)) }]; + // sharp может вернуть другой формат — определим по сигнатуре + images[0].mimetype = sniffMime(scaled.buffer) || images[0].mimetype; + + let refRaw = null; + if (referenceId) { + try { + refRaw = (await history.getVersionFile(referenceId)).buffer; + } catch (err) { + logger.warn("Не удалось прочитать референс по referenceId", { referenceId, error: err.message }); + } + } else if (referenceBuffer) { + refRaw = Buffer.isBuffer(referenceBuffer) ? referenceBuffer : Buffer.from(referenceBuffer); + } + + let scaledRegion = null; + if (refRaw) { + let refBuf = refRaw; + scaledRegion = region || null; + if (scaled.downscaled && factor < 1) { + refBuf = (await downscaleImage(refRaw, maxDim)).buffer; + if (region) scaledRegion = scaleRegion(region, factor); + } + images.push({ buffer: refBuf, mimetype: sniffMime(refBuf) || mimeFromExt(extFromMime(referenceMimetype)) }); + } + + const promptText = buildGrokEditPrompt({ prompt, hasReference: Boolean(refRaw), region: scaledRegion }); + + logger.info("Запуск Grok редактирования", { + jobId: job.id, + model: xaiClient.model, + prompt: truncate(prompt, 100), + downscaled: scaled.downscaled, + width: scaled.width, + height: scaled.height, + }); + + let result; + try { + result = await xaiClient.edit({ prompt: promptText, images, timeoutMs: config.XAI_TIMEOUT_MS }); + } catch (err) { + logger.warn("Grok редактирование завершилось ошибкой", { jobId: job.id, error: err.message }); + return { + ok: false, + error: err.timedOut ? "Обработка заняла больше времени и была остановлена по таймауту." : err.message, + details: { status: err.status, timedOut: Boolean(err.timedOut) }, + }; + } + + const ext = extFromMime(result.mimetype); + const dims = getImageDimensions(result.buffer) || { width: scaled.width, height: scaled.height }; + const version = await history.createVersion({ + kind: "edit", + label: `Правка: ${truncate(prompt, 30)}`, + prompt, + parentId: sourceId || null, + referenceId: referenceId || null, + buffer: result.buffer, + ext, + width: dims.width, + height: dims.height, + }); + + return { + ok: true, + version, + extra: { + engine: "grok", + model: xaiClient.model, + downscaledFrom: scaled.downscaled ? { width: scaled.originalWidth, height: scaled.originalHeight } : null, + durationMs: result.durationMs, + }, + }; + }, + }; +} + +function sniffMime(buf) { + if (!buf || buf.length < 12) return null; + if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg"; + if (buf[0] === 0x89 && buf[1] === 0x50) return "image/png"; + if (buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "image/webp"; + return null; +} + +module.exports = { createGrokEditEngine, buildGrokEditPrompt }; diff --git a/src/core/xai/client.js b/src/core/xai/client.js new file mode 100644 index 0000000..08ed8a1 --- /dev/null +++ b/src/core/xai/client.js @@ -0,0 +1,87 @@ +"use strict"; + +/** + * Клиент xAI Imagine API для редактирования изображений. + * Документация: https://docs.x.ai/docs/guides/image-generation (раздел Image Editing) + * POST {XAI_BASE_URL}/images/edits, JSON: + * { model, prompt, image: {type:"image_url", url}, images?: [...], response_format: "b64_json" } + * Ответ: { data: [{ b64_json, mime_type }], usage } + */ +class XaiImageClient { + constructor({ apiKey, baseUrl = "https://api.x.ai/v1", model = "grok-imagine-image-2.0", timeoutMs = 180000, fetchImpl } = {}) { + this.apiKey = apiKey; + this.baseUrl = String(baseUrl).replace(/\/+$/, ""); + this.model = model; + this.timeoutMs = timeoutMs; + this.fetch = fetchImpl || globalThis.fetch; + } + + static toDataUri(buffer, mimetype = "image/jpeg") { + return `data:${mimetype};base64,${Buffer.from(buffer).toString("base64")}`; + } + + /** + * @param {Object} o + * @param {string} o.prompt + * @param {{buffer: Buffer, mimetype: string}[]} o.images - первое — исходное фото (до 5 шт.) + * @returns {Promise<{buffer: Buffer, mimetype: string, durationMs: number, usage?: any}>} + */ + async edit({ prompt, images, timeoutMs }) { + if (!this.apiKey) { + throw new Error("XAI_API_KEY не задан — редактирование через Grok недоступно."); + } + if (!images || images.length === 0) throw new Error("Нет исходного изображения."); + const parts = images.slice(0, 5).map((img) => ({ + type: "image_url", + url: XaiImageClient.toDataUri(img.buffer, img.mimetype), + })); + const body = { model: this.model, prompt, response_format: "b64_json" }; + if (parts.length === 1) body.image = parts[0]; + else body.images = parts; + + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs); + let res; + try { + res = await this.fetch(`${this.baseUrl}/images/edits`, { + method: "POST", + headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (err.name === "AbortError") { + const e = new Error("Grok: превышен таймаут запроса."); + e.timedOut = true; + throw e; + } + throw new Error(`Grok: ошибка сети: ${err.message}`); + } finally { + clearTimeout(timer); + } + + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* not json */ } + if (!res.ok) { + const msg = json?.error?.message || json?.error || json?.code || text.slice(0, 300); + const e = new Error(`Grok API ${res.status}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); + e.status = res.status; + throw e; + } + const item = json?.data?.[0]; + if (!item) throw new Error("Grok API вернул пустой ответ."); + let buffer; + if (item.b64_json) buffer = Buffer.from(item.b64_json, "base64"); + else if (item.url) { + const r = await this.fetch(item.url); + if (!r.ok) throw new Error(`Grok: не удалось скачать результат (${r.status}).`); + buffer = Buffer.from(await r.arrayBuffer()); + } else throw new Error("Grok API не вернул изображение."); + + return { buffer, mimetype: item.mime_type || "image/jpeg", durationMs: Date.now() - startedAt, usage: json.usage }; + } +} + +module.exports = { XaiImageClient }; diff --git a/src/server.js b/src/server.js index a48d62b..17e1808 100644 --- a/src/server.js +++ b/src/server.js @@ -18,6 +18,8 @@ const { ComfyClient } = require("./core/comfy/comfy-client"); const { ComfyRuntime } = require("./core/comfy/comfy-runtime"); const { CodexCli } = require("./core/codex/cli"); const { EngineRegistry } = require("./core/engines/engine"); +const { XaiImageClient } = require("./core/xai/client"); +const { createGrokEditEngine } = require("./core/engines/grok-edit-engine"); const { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine"); const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine"); const { createApp } = require("./http/app"); @@ -102,6 +104,16 @@ async function startServer() { logger, }); + const xaiClient = new XaiImageClient({ + apiKey: config.XAI_API_KEY, + baseUrl: config.XAI_BASE_URL, + model: config.XAI_IMAGE_MODEL, + timeoutMs: config.XAI_TIMEOUT_MS, + }); + if (config.EDIT_ENGINE === "grok" && !config.XAI_API_KEY) { + logger.warn("XAI_API_KEY не задан: редактирование через Grok будет возвращать ошибку"); + } + const events = new EventEmitter(); events.setMaxListeners(100); @@ -114,9 +126,12 @@ async function startServer() { comfyClient, comfyRuntime, codexCli, + xaiClient, }; - engineRegistry.register("edit", (ctx) => createCodexEditEngine(ctx)); + engineRegistry.register("edit", (ctx) => + config.EDIT_ENGINE === "codex" ? createCodexEditEngine(ctx) : createGrokEditEngine(ctx) + ); engineRegistry.register("suggest", (ctx) => createCodexSuggestEngine(ctx)); engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx)); diff --git a/test/grok-edit.test.js b/test/grok-edit.test.js new file mode 100644 index 0000000..ed591cf --- /dev/null +++ b/test/grok-edit.test.js @@ -0,0 +1,60 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { XaiImageClient } = require("../src/core/xai/client"); +const { buildGrokEditPrompt } = require("../src/core/engines/grok-edit-engine"); +const { loadConfig } = require("../src/config"); + +describe("xAI Grok image edit", () => { + test("sends JSON to /images/edits and decodes b64_json", async () => { + let captured; + const fetchImpl = async (url, opts) => { + captured = { url, opts, body: JSON.parse(opts.body) }; + return new Response(JSON.stringify({ data: [{ b64_json: Buffer.from("JPEGDATA").toString("base64"), mime_type: "image/jpeg" }] }), { status: 200 }); + }; + const c = new XaiImageClient({ apiKey: "k", baseUrl: "https://api.x.ai/v1/", fetchImpl }); + const r = await c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a"), mimetype: "image/png" }] }); + assert.equal(captured.url, "https://api.x.ai/v1/images/edits"); + assert.equal(captured.opts.headers.Authorization, "Bearer k"); + assert.equal(captured.body.model, "grok-imagine-image-2.0"); + assert.equal(captured.body.response_format, "b64_json"); + assert.equal(captured.body.image.type, "image_url"); + assert.ok(captured.body.image.url.startsWith("data:image/png;base64,")); + assert.equal(r.buffer.toString(), "JPEGDATA"); + }); + + test("uses images[] for multiple inputs and surfaces API errors", async () => { + let body; + const fetchImpl = async (_u, o) => { + body = JSON.parse(o.body); + return new Response(JSON.stringify({ error: "bad" }), { status: 400 }); + }; + const c = new XaiImageClient({ apiKey: "k", fetchImpl }); + const img = { buffer: Buffer.from("a"), mimetype: "image/jpeg" }; + await assert.rejects(c.edit({ prompt: "p", images: [img, img] }), /Grok API 400: bad/); + assert.equal(body.images.length, 2); + assert.equal(body.image, undefined); + }); + + test("fails clearly without API key", async () => { + const c = new XaiImageClient({ apiKey: "" }); + await assert.rejects(c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a") }] }), /XAI_API_KEY/); + }); + + test("prompt mentions reference region", () => { + const t = buildGrokEditPrompt({ prompt: "add hat", hasReference: true, region: { x: 1, y: 2, w: 3, h: 4 } }); + assert.match(t, /add hat/); + assert.match(t, /x=1, y=2, width=3, height=4/); + }); + + test("config defaults and key is not in sanitized output", () => { + const cfg = loadConfig({ XAI_API_KEY: "secret" }); + assert.equal(cfg.EDIT_ENGINE, "grok"); + assert.equal(cfg.XAI_IMAGE_MODEL, "grok-imagine-image-2.0"); + const s = cfg.getSanitized(); + assert.equal(s.XAI_API_KEY, undefined); + assert.equal(s.XAI_API_KEY_SET, true); + assert.throws(() => loadConfig({ EDIT_ENGINE: "foo" })); + }); +});