* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
467 lines
22 KiB
JavaScript
467 lines
22 KiB
JavaScript
import { join, isAbsolute, dirname } from 'path';
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||
import { fileURLToPath } from 'url';
|
||
import { randomBytes, randomUUID } from 'crypto';
|
||
|
||
const ROOT_DIR = dirname(fileURLToPath(import.meta.url));
|
||
const PROJECT_DIR = join(ROOT_DIR, '..');
|
||
const DEFAULT_WORKFLOW_RELATIVE = 'local-ai/workflows/vendoo_flux_schnell_api.json';
|
||
|
||
function toPosix(path) {
|
||
return String(path || '').replace(/\\/g, '/');
|
||
}
|
||
|
||
function resolveWorkflowPath(pathValue = '') {
|
||
const trimmed = String(pathValue || '').trim();
|
||
if (!trimmed) return join(PROJECT_DIR, DEFAULT_WORKFLOW_RELATIVE);
|
||
return isAbsolute(trimmed) ? trimmed : join(PROJECT_DIR, trimmed);
|
||
}
|
||
|
||
function getEnvBool(value, fallback = false) {
|
||
if (value === undefined || value === null || value === '') return fallback;
|
||
return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase());
|
||
}
|
||
|
||
export function generateLocalImageToken() {
|
||
return randomBytes(24).toString('base64url');
|
||
}
|
||
|
||
|
||
|
||
export function getLocalImageConfig(settings = {}) {
|
||
const workflowRelative = String(settings.ai_model_photo_local_workflow_path || process.env.LOCAL_IMAGE_WORKFLOW_PATH || DEFAULT_WORKFLOW_RELATIVE).trim() || DEFAULT_WORKFLOW_RELATIVE;
|
||
const workflowPath = resolveWorkflowPath(workflowRelative);
|
||
return {
|
||
enabled: getEnvBool(settings.ai_model_photo_local_enabled ?? process.env.LOCAL_IMAGE_ENABLED, true),
|
||
url: String(settings.ai_model_photo_local_url || process.env.LOCAL_IMAGE_URL || 'http://127.0.0.1:8188').trim().replace(/\/$/, ''),
|
||
token: String(settings.ai_model_photo_local_token || process.env.LOCAL_IMAGE_TOKEN || '').trim(),
|
||
workflow_path: workflowPath,
|
||
workflow_relative: toPosix(workflowRelative),
|
||
install_path: String(settings.ai_model_photo_local_install_path || process.env.LOCAL_IMAGE_INSTALL_PATH || '').trim(),
|
||
require_token: getEnvBool(settings.ai_model_photo_local_require_token ?? process.env.LOCAL_IMAGE_REQUIRE_TOKEN, true),
|
||
allow_remote: getEnvBool(process.env.LOCAL_IMAGE_ALLOW_REMOTE, false),
|
||
};
|
||
}
|
||
|
||
function assertSafeLocalUrl(config) {
|
||
let parsed;
|
||
try { parsed = new URL(config.url); } catch { throw new Error('Ungültige lokale Bildserver-URL.'); }
|
||
const host = String(parsed.hostname || '').toLowerCase();
|
||
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host);
|
||
if (!loopback && !config.allow_remote) {
|
||
throw new Error('Aus Sicherheitsgründen sind für lokalen FLUX standardmäßig nur localhost/127.0.0.1 erlaubt.');
|
||
}
|
||
return { loopback, host };
|
||
}
|
||
|
||
function getAuthHeaders(config) {
|
||
const headers = {};
|
||
if (config.token) {
|
||
headers['Authorization'] = `Bearer ${config.token}`;
|
||
headers['X-API-Key'] = config.token;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
function getHeaders(config) {
|
||
return { ...getAuthHeaders(config), 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
|
||
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
return await fetch(url, { ...options, signal: controller.signal });
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
function replaceDeep(value, replacements) {
|
||
if (typeof value === 'string') {
|
||
let out = value;
|
||
for (const [key, val] of Object.entries(replacements)) {
|
||
out = out.split(key).join(String(val));
|
||
}
|
||
return out;
|
||
}
|
||
if (Array.isArray(value)) return value.map(item => replaceDeep(item, replacements));
|
||
if (value && typeof value === 'object') {
|
||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceDeep(v, replacements)]));
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function firstImageFromHistory(history = {}) {
|
||
for (const node of Object.values(history?.outputs || {})) {
|
||
if (Array.isArray(node?.images) && node.images.length) return node.images[0];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function pollHistory(config, promptId, timeoutMs = 300000, onProgress = null, { onPhase = null, isCancelled = null } = {}) {
|
||
const started = Date.now();
|
||
let lastPayload = null;
|
||
while (Date.now() - started < timeoutMs) {
|
||
if (typeof isCancelled === 'function' && await isCancelled()) {
|
||
const error = new Error('FLUX-Auftrag wurde abgebrochen.');
|
||
error.code = 'FLUX_CANCELLED';
|
||
throw error;
|
||
}
|
||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - started) / 1000));
|
||
const message = elapsedSeconds < 8
|
||
? 'ComfyUI übernimmt den Auftrag …'
|
||
: `FLUX rendert das Bild seit ${elapsedSeconds} Sekunden …`;
|
||
if (typeof onPhase === 'function') onPhase('rendering', message, { elapsed_seconds: elapsedSeconds });
|
||
if (typeof onProgress === 'function') {
|
||
const estimated = Math.min(88, 24 + Math.round((elapsedSeconds / 150) * 60));
|
||
onProgress(estimated, message);
|
||
}
|
||
const response = await fetchWithTimeout(`${config.url}/history/${promptId}`, { headers: getHeaders(config) }, 12000);
|
||
const payload = await response.json().catch(() => ({}));
|
||
lastPayload = payload?.[promptId] || payload;
|
||
const image = firstImageFromHistory(lastPayload);
|
||
if (image) return { history: lastPayload, image };
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
throw new Error('Lokale FLUX/ComfyUI-Generierung hat nach fünf Minuten das Zeitlimit überschritten.');
|
||
}
|
||
|
||
async function fetchComfyImageAsBase64(config, imageInfo) {
|
||
const params = new URLSearchParams({ filename: imageInfo.filename, subfolder: imageInfo.subfolder || '', type: imageInfo.type || 'output' });
|
||
const response = await fetchWithTimeout(`${config.url}/view?${params.toString()}`, { headers: getHeaders(config) }, 60000);
|
||
if (!response.ok) throw new Error(`ComfyUI-Bildabruf fehlgeschlagen (${response.status})`);
|
||
return Buffer.from(await response.arrayBuffer()).toString('base64');
|
||
}
|
||
|
||
function parseDataUrl(dataUrl = '') {
|
||
const match = String(dataUrl || '').match(/^data:([^;,]+);base64,(.+)$/s);
|
||
if (!match) return null;
|
||
return { mime: match[1] || 'image/jpeg', buffer: Buffer.from(match[2], 'base64') };
|
||
}
|
||
|
||
async function uploadSourceImage(config, sourceDataUrl, onProgress = null) {
|
||
const parsed = parseDataUrl(sourceDataUrl);
|
||
if (!parsed?.buffer?.length) return null;
|
||
if (typeof onProgress === 'function') onProgress(8, 'Produktfotoreferenz wird an ComfyUI übertragen …');
|
||
const extension = parsed.mime.includes('png') ? 'png' : parsed.mime.includes('webp') ? 'webp' : 'jpg';
|
||
const filename = `vendoo-reference-${randomUUID()}.${extension}`;
|
||
const form = new FormData();
|
||
form.append('image', new Blob([parsed.buffer], { type: parsed.mime }), filename);
|
||
form.append('type', 'input');
|
||
form.append('overwrite', 'true');
|
||
const response = await fetchWithTimeout(`${config.url}/upload/image`, {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(config),
|
||
body: form,
|
||
}, 60000);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || !payload?.name) {
|
||
const detail = payload?.error || payload?.message || `HTTP ${response.status}`;
|
||
throw new Error(`Produktfotoreferenz konnte nicht an ComfyUI übertragen werden: ${detail}`);
|
||
}
|
||
const imageName = payload.subfolder ? `${payload.subfolder}/${payload.name}` : payload.name;
|
||
return { imageName, payload };
|
||
}
|
||
|
||
function applySourceImageConditioning(workflow, imageName, mode = 'model', referenceStrength = 70, resolution = 768) {
|
||
if (!imageName || !workflow?.['7']?.inputs || !workflow?.['3']) return { workflow, used: false, denoise: 1 };
|
||
const strength = Math.max(30, Math.min(90, Number(referenceStrength || 70)));
|
||
const targetSize = Number(resolution) === 1024 ? 1024 : 768;
|
||
const baseDenoise = mode === 'flatlay' ? 0.52 : mode === 'ghost_mannequin' ? 0.64 : 0.80;
|
||
const denoise = Math.max(0.42, Math.min(0.88, Number((baseDenoise - ((strength - 60) * 0.0035)).toFixed(3))));
|
||
workflow['10'] = { inputs: { image: imageName, upload: 'image' }, class_type: 'LoadImage' };
|
||
workflow['11'] = {
|
||
inputs: { upscale_method: 'lanczos', width: targetSize, height: targetSize, crop: 'disabled', image: ['10', 0] },
|
||
class_type: 'ImageScale',
|
||
};
|
||
workflow['12'] = { inputs: { pixels: ['11', 0], vae: ['3', 2] }, class_type: 'VAEEncode' };
|
||
workflow['7'].inputs.latent_image = ['12', 0];
|
||
workflow['7'].inputs.denoise = denoise;
|
||
return { workflow, used: true, denoise };
|
||
}
|
||
|
||
export async function getLocalImageStatus(settings = {}) {
|
||
const config = getLocalImageConfig(settings);
|
||
const workflowExists = existsSync(config.workflow_path);
|
||
const configuredInstallRoot = config.install_path
|
||
? (isAbsolute(config.install_path) ? config.install_path : join(PROJECT_DIR, config.install_path))
|
||
: join(PROJECT_DIR, 'local-ai', 'comfyui');
|
||
const portableRoot = existsSync(join(configuredInstallRoot, 'ComfyUI', 'main.py'))
|
||
? configuredInstallRoot
|
||
: join(configuredInstallRoot, 'ComfyUI_windows_portable');
|
||
const comfyMain = join(portableRoot, 'ComfyUI', 'main.py');
|
||
const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors');
|
||
const status = {
|
||
enabled: config.enabled,
|
||
url: config.url,
|
||
token_set: !!config.token,
|
||
require_token: config.require_token,
|
||
workflow_path: config.workflow_relative,
|
||
workflow_exists: workflowExists,
|
||
install_path: config.install_path,
|
||
comfy_installed: existsSync(comfyMain),
|
||
model_installed: existsSync(modelPath),
|
||
model_path: modelPath,
|
||
reachable: false,
|
||
installed: existsSync(comfyMain),
|
||
localhost_only: !config.allow_remote,
|
||
reference_image_supported: true,
|
||
message: '',
|
||
};
|
||
try {
|
||
const security = assertSafeLocalUrl(config);
|
||
status.loopback = security.loopback;
|
||
const res = await fetchWithTimeout(`${config.url}/system_stats`, { headers: getHeaders(config) }, 8000);
|
||
status.reachable = res.ok;
|
||
status.installed = res.ok;
|
||
const payload = await res.json().catch(() => ({}));
|
||
status.system = payload?.system || payload || null;
|
||
status.message = res.ok
|
||
? `Lokaler Bildserver erreichbar${workflowExists ? '' : ' · Workflow-Datei fehlt noch'}.`
|
||
: `Lokaler Bildserver antwortet mit HTTP ${res.status}.`;
|
||
} catch (error) {
|
||
status.message = `Lokaler Bildserver nicht erreichbar: ${error.message || error}`;
|
||
}
|
||
return status;
|
||
}
|
||
|
||
export async function generateWithLocalFlux({
|
||
prompt,
|
||
settings = {},
|
||
mode = 'model',
|
||
variantIndex = 0,
|
||
sourceDataUrl = '',
|
||
onProgress = null,
|
||
onPhase = null,
|
||
onSubmitted = null,
|
||
isCancelled = null,
|
||
width = null,
|
||
height = null,
|
||
seed = null,
|
||
steps = null,
|
||
}) {
|
||
const config = getLocalImageConfig(settings);
|
||
const throwIfCancelled = async () => {
|
||
if (typeof isCancelled === 'function' && await isCancelled()) {
|
||
const error = new Error('FLUX-Auftrag wurde abgebrochen.');
|
||
error.code = 'FLUX_CANCELLED';
|
||
throw error;
|
||
}
|
||
};
|
||
await throwIfCancelled();
|
||
if (!config.enabled) throw new Error('Lokale FLUX-Bildgenerierung ist deaktiviert.');
|
||
assertSafeLocalUrl(config);
|
||
if (!existsSync(config.workflow_path)) throw new Error(`Workflow-Datei nicht gefunden: ${config.workflow_relative}`);
|
||
if (typeof onPhase === 'function') onPhase('preparing', 'Lokaler FLUX-Workflow wird vorbereitet …');
|
||
if (typeof onProgress === 'function') onProgress(3, 'Lokaler FLUX-Workflow wird vorbereitet …');
|
||
const workflowText = readFileSync(config.workflow_path, 'utf8').replace(/^/, '');
|
||
let raw;
|
||
try {
|
||
raw = JSON.parse(workflowText);
|
||
} catch (error) {
|
||
throw new Error(`Lokaler FLUX-Workflow ist kein gültiges JSON: ${error.message || error}`);
|
||
}
|
||
const replacements = {
|
||
'__PROMPT__': prompt,
|
||
'__NEGATIVE_PROMPT__': mode === 'prompt' ? '' : 'nsfw, nudity, lingerie, child, low quality, extra limbs, cropped garment, wrong garment, different clothing, changed logo, changed color',
|
||
'__SEED__': Number.isFinite(Number(seed)) ? Math.max(0, Math.min(2147483647, Number(seed))) : Math.floor(Math.random() * 2147483647),
|
||
'__MODE__': mode,
|
||
'__VARIANT_INDEX__': variantIndex + 1,
|
||
'__SOURCE_IMAGE__': sourceDataUrl || '',
|
||
'__CLIENT_ID__': 'vendoo',
|
||
};
|
||
let workflow = replaceDeep(raw, replacements);
|
||
if (workflow?.['7']?.inputs) workflow['7'].inputs.seed = replacements.__SEED__;
|
||
const resolution = Number(settings.ai_model_photo_local_resolution) === 1024 ? 1024 : 768;
|
||
const targetWidth = Number(width) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(width) / 8) * 8)) : resolution;
|
||
const targetHeight = Number(height) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(height) / 8) * 8)) : resolution;
|
||
if (workflow?.['6']?.inputs) {
|
||
workflow['6'].inputs.width = targetWidth;
|
||
workflow['6'].inputs.height = targetHeight;
|
||
}
|
||
const targetSteps = Number.isFinite(Number(steps)) ? Math.max(4, Math.min(20, Math.round(Number(steps)))) : null;
|
||
if (targetSteps && workflow?.['7']?.inputs) workflow['7'].inputs.steps = targetSteps;
|
||
if (mode === 'prompt' && workflow?.['7']?.inputs) {
|
||
workflow['7'].inputs.cfg = 1;
|
||
workflow['7'].inputs.sampler_name = 'euler';
|
||
workflow['7'].inputs.scheduler = 'simple';
|
||
workflow['7'].inputs.denoise = 1;
|
||
}
|
||
const useReference = getEnvBool(settings.ai_model_photo_use_source_reference, true) && !!sourceDataUrl;
|
||
let reference = { used: false, denoise: 1 };
|
||
if (useReference) {
|
||
const uploaded = await uploadSourceImage(config, sourceDataUrl, onProgress);
|
||
if (uploaded?.imageName) reference = applySourceImageConditioning(workflow, uploaded.imageName, mode, settings.ai_model_photo_reference_strength || 70, resolution);
|
||
}
|
||
if (typeof onPhase === 'function') onPhase('submitting', reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.');
|
||
if (typeof onProgress === 'function') onProgress(16, reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.');
|
||
await throwIfCancelled();
|
||
const submit = await fetchWithTimeout(`${config.url}/prompt`, {
|
||
method: 'POST',
|
||
headers: getHeaders(config),
|
||
body: JSON.stringify({ client_id: `vendoo-${randomUUID()}`, prompt: workflow }),
|
||
}, 30000);
|
||
const submitPayload = await submit.json().catch(() => ({}));
|
||
if (!submit.ok || !submitPayload.prompt_id) {
|
||
const message = submitPayload?.error?.message || submitPayload?.error || submitPayload?.message || `HTTP ${submit.status}`;
|
||
throw new Error(`ComfyUI-Prompt fehlgeschlagen: ${message}`);
|
||
}
|
||
if (typeof onSubmitted === 'function') await onSubmitted(submitPayload.prompt_id);
|
||
try {
|
||
await throwIfCancelled();
|
||
} catch (error) {
|
||
try { await cancelLocalFluxPrompt(submitPayload.prompt_id, settings); } catch {}
|
||
throw error;
|
||
}
|
||
if (typeof onPhase === 'function') onPhase('submitted', 'Auftrag wurde an FLUX übergeben …', { prompt_id: submitPayload.prompt_id });
|
||
if (typeof onProgress === 'function') onProgress(22, 'Auftrag wurde an FLUX übergeben …');
|
||
const done = await pollHistory(config, submitPayload.prompt_id, 300000, onProgress, { onPhase, isCancelled });
|
||
if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …');
|
||
if (typeof onProgress === 'function') onProgress(92, 'Fertiges Bild wird von ComfyUI geladen …');
|
||
const base64 = await fetchComfyImageAsBase64(config, done.image);
|
||
if (typeof onPhase === 'function') onPhase('completed', 'Lokales FLUX-Bild wurde erzeugt.');
|
||
if (typeof onProgress === 'function') onProgress(100, 'Lokales FLUX-Bild wurde erzeugt.');
|
||
return {
|
||
base64,
|
||
model: 'flux-schnell-local',
|
||
provider: 'local',
|
||
label: 'Lokales FLUX / ComfyUI',
|
||
free: true,
|
||
prompt_id: submitPayload.prompt_id,
|
||
reference_used: reference.used,
|
||
reference_denoise: reference.denoise,
|
||
resolution,
|
||
width: targetWidth,
|
||
height: targetHeight,
|
||
seed: replacements.__SEED__,
|
||
steps: targetSteps || workflow?.['7']?.inputs?.steps || 6,
|
||
};
|
||
}
|
||
|
||
|
||
export async function waitForLocalFluxPrompt({ promptId, settings = {}, timeoutMs = 300000, onPhase = null, isCancelled = null } = {}) {
|
||
const config = getLocalImageConfig(settings);
|
||
if (!promptId) throw new Error('ComfyUI-Prompt-ID fehlt.');
|
||
assertSafeLocalUrl(config);
|
||
const done = await pollHistory(config, String(promptId), timeoutMs, null, { onPhase, isCancelled });
|
||
if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …');
|
||
const base64 = await fetchComfyImageAsBase64(config, done.image);
|
||
return { base64, prompt_id: String(promptId), history: done.history, image: done.image };
|
||
}
|
||
|
||
export async function interruptLocalFlux(settings = {}) {
|
||
const config = getLocalImageConfig(settings);
|
||
assertSafeLocalUrl(config);
|
||
const response = await fetchWithTimeout(`${config.url}/interrupt`, {
|
||
method: 'POST',
|
||
headers: getHeaders(config),
|
||
body: JSON.stringify({}),
|
||
}, 15000);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Abbruch fehlgeschlagen (${response.status})`);
|
||
return { ok: true, payload };
|
||
}
|
||
|
||
function comfyQueuePromptIds(items = []) {
|
||
return (Array.isArray(items) ? items : [])
|
||
.map(item => Array.isArray(item) ? item[1] : item?.prompt_id || item?.id)
|
||
.filter(Boolean)
|
||
.map(String);
|
||
}
|
||
|
||
export async function cancelLocalFluxPrompt(promptId, settings = {}) {
|
||
const id = String(promptId || '').trim();
|
||
if (!id) return { ok: false, mode: 'not_submitted' };
|
||
const config = getLocalImageConfig(settings);
|
||
assertSafeLocalUrl(config);
|
||
|
||
const queueResponse = await fetchWithTimeout(`${config.url}/queue`, { headers: getHeaders(config) }, 12000);
|
||
const queue = await queueResponse.json().catch(() => ({}));
|
||
if (!queueResponse.ok) throw new Error(queue?.error || queue?.message || `ComfyUI-Queue konnte nicht gelesen werden (${queueResponse.status})`);
|
||
|
||
const runningIds = comfyQueuePromptIds(queue?.queue_running);
|
||
const pendingIds = comfyQueuePromptIds(queue?.queue_pending);
|
||
if (pendingIds.includes(id)) {
|
||
const response = await fetchWithTimeout(`${config.url}/queue`, {
|
||
method: 'POST',
|
||
headers: getHeaders(config),
|
||
body: JSON.stringify({ delete: [id] }),
|
||
}, 15000);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Warteschlangenauftrag konnte nicht entfernt werden (${response.status})`);
|
||
return { ok: true, mode: 'dequeued', prompt_id: id, payload };
|
||
}
|
||
if (runningIds.includes(id)) {
|
||
const interrupted = await interruptLocalFlux(settings);
|
||
return { ...interrupted, mode: 'interrupted', prompt_id: id };
|
||
}
|
||
return { ok: true, mode: 'already_finished_or_missing', prompt_id: id };
|
||
}
|
||
|
||
export async function getLocalFluxRuntimeInfo(settings = {}, { historyLimit = 20 } = {}) {
|
||
const config = getLocalImageConfig(settings);
|
||
assertSafeLocalUrl(config);
|
||
const fetchJson = async (path, timeout = 12000) => {
|
||
const response = await fetchWithTimeout(`${config.url}${path}`, { headers: getHeaders(config) }, timeout);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(`${path} antwortet mit HTTP ${response.status}`);
|
||
return payload;
|
||
};
|
||
const [systemResult, queueResult, historyResult] = await Promise.allSettled([
|
||
fetchJson('/system_stats'),
|
||
fetchJson('/queue'),
|
||
fetchJson(`/history?max_items=${Math.max(1, Math.min(100, Number(historyLimit) || 20))}`),
|
||
]);
|
||
const systemStats = systemResult.status === 'fulfilled' ? systemResult.value : null;
|
||
const queue = queueResult.status === 'fulfilled' ? queueResult.value : null;
|
||
const history = historyResult.status === 'fulfilled' ? historyResult.value : null;
|
||
const running = Array.isArray(queue?.queue_running) ? queue.queue_running.length : 0;
|
||
const pending = Array.isArray(queue?.queue_pending) ? queue.queue_pending.length : 0;
|
||
const historyItems = history && typeof history === 'object' ? Object.entries(history).slice(0, Math.max(1, Number(historyLimit) || 20)) : [];
|
||
return {
|
||
reachable: !!systemStats || !!queue || !!history,
|
||
url: config.url,
|
||
system_stats: systemStats,
|
||
queue,
|
||
history: Object.fromEntries(historyItems),
|
||
queue_summary: { running, pending, total: running + pending },
|
||
errors: {
|
||
system_stats: systemResult.status === 'rejected' ? systemResult.reason?.message || String(systemResult.reason) : null,
|
||
queue: queueResult.status === 'rejected' ? queueResult.reason?.message || String(queueResult.reason) : null,
|
||
history: historyResult.status === 'rejected' ? historyResult.reason?.message || String(historyResult.reason) : null,
|
||
},
|
||
};
|
||
}
|
||
|
||
|
||
export function ensureLocalFluxBootstrap({ token = '', installPath = '' } = {}) {
|
||
const localDir = join(PROJECT_DIR, 'local-ai');
|
||
const workflowsDir = join(localDir, 'workflows');
|
||
mkdirSync(workflowsDir, { recursive: true });
|
||
const workflowPath = join(workflowsDir, 'vendoo_flux_schnell_api.json');
|
||
if (!existsSync(workflowPath)) {
|
||
const workflow = {
|
||
'3': { inputs: { ckpt_name: 'flux1-schnell-fp8.safetensors' }, class_type: 'CheckpointLoaderSimple' },
|
||
'4': { inputs: { text: '__PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' },
|
||
'5': { inputs: { text: '__NEGATIVE_PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' },
|
||
'6': { inputs: { width: 1024, height: 1024, batch_size: 1 }, class_type: 'EmptyLatentImage' },
|
||
'7': { inputs: { seed: '__SEED__', steps: 6, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1, model: ['3', 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] }, class_type: 'KSampler' },
|
||
'8': { inputs: { samples: ['7', 0], vae: ['3', 2] }, class_type: 'VAEDecode' },
|
||
'9': { inputs: { filename_prefix: 'Vendoo/flux_prompt', images: ['8', 0] }, class_type: 'SaveImage' },
|
||
};
|
||
writeFileSync(workflowPath, JSON.stringify(workflow, null, 2), 'utf8');
|
||
}
|
||
const envLocalPath = join(localDir, '.env.local-flux');
|
||
if (!existsSync(envLocalPath)) {
|
||
writeFileSync(envLocalPath, [
|
||
'LOCAL_IMAGE_URL=http://127.0.0.1:8188',
|
||
`LOCAL_IMAGE_TOKEN=${token || generateLocalImageToken()}`,
|
||
`LOCAL_IMAGE_WORKFLOW_PATH=${DEFAULT_WORKFLOW_RELATIVE}`,
|
||
`LOCAL_IMAGE_INSTALL_PATH=${installPath}`,
|
||
].join('\n') + '\n', 'utf8');
|
||
}
|
||
return { workflow_path: toPosix(DEFAULT_WORKFLOW_RELATIVE), env_file: toPosix('local-ai/.env.local-flux') };
|
||
}
|