feat: редактирование фото через xAI Grok Imagine API (/v1/images/edits) вместо Codex CLI #1
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
145
src/core/engines/grok-edit-engine.js
Normal file
145
src/core/engines/grok-edit-engine.js
Normal file
@@ -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 };
|
||||
87
src/core/xai/client.js
Normal file
87
src/core/xai/client.js
Normal file
@@ -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 };
|
||||
@@ -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));
|
||||
|
||||
|
||||
60
test/grok-edit.test.js
Normal file
60
test/grok-edit.test.js
Normal file
@@ -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" }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user