Integrate Cloudflare Workers AI text-to-image into SD extension (#5434)

* feat: integrate Cloudflare Workers AI for text-to-image generation in SD extension

Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/efc79e4d-2119-4cdb-8afb-f26e318a38ef

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* fix: address review - use oai_settings for account ID, sort dropdown alphabetically, remove Account ID input, move debug log

Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/bf0dda38-df40-44f4-8a63-0c952b48905d

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Clean-up diffs

* feat: add refresh models button to Workers AI section

Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/ab6b5e7a-84d2-44d1-9f6e-3d330de04ef1

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* fix: revert unrelated package-lock.json changes

Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/ab6b5e7a-84d2-44d1-9f6e-3d330de04ef1

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Fix models loading

* refactor: update model refresh button ID and add class to select elements

* Send formData to BFL models

* fix: adjust use FormData condition

* fix: validate Workers AI account ID before proceeding with image model loading

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Copilot
2026-04-15 22:00:08 +03:00
committed by GitHub
parent 64c96e895c
commit 78628f7dbb
4 changed files with 252 additions and 21 deletions
@@ -99,6 +99,7 @@ const sources = {
google: 'google',
zai: 'zai',
openrouter: 'openrouter',
workersai: 'workersai',
};
const comfyTypes = {
standard: 'standard',
@@ -1747,6 +1748,9 @@ async function loadSamplers() {
case sources.openrouter:
samplers = ['N/A'];
break;
case sources.workersai:
samplers = ['N/A'];
break;
}
for (const sampler of samplers) {
@@ -1997,6 +2001,9 @@ async function loadModels() {
case sources.openrouter:
models = await loadOpenRouterModels();
break;
case sources.workersai:
models = await loadWorkersAIImageModels();
break;
}
if (extension_settings.sd.source === sources.electronhub) {
@@ -2124,6 +2131,33 @@ async function loadXAIModels() {
];
}
async function loadWorkersAIImageModels() {
$('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]);
if (!secret_state[SECRET_KEYS.WORKERS_AI]) {
return [];
}
if (!oai_settings.workers_ai_account_id) {
toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation');
return [];
}
const result = await fetch('/api/sd/workersai/models', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
account_id: oai_settings.workers_ai_account_id,
}),
});
if (result.ok) {
return await result.json();
}
return [];
}
async function loadPollinationsModels() {
$('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
@@ -2609,6 +2643,9 @@ async function loadSchedulers() {
case sources.openrouter:
schedulers = ['N/A'];
break;
case sources.workersai:
schedulers = ['N/A'];
break;
}
for (const scheduler of schedulers) {
@@ -2729,6 +2766,9 @@ async function loadVaes() {
case sources.openrouter:
vaes = ['N/A'];
break;
case sources.workersai:
vaes = ['N/A'];
break;
}
for (const vae of vaes) {
@@ -3432,6 +3472,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
case sources.openrouter:
result = await generateOpenRouterImage(prefixedPrompt, signal);
break;
case sources.workersai:
result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signal);
break;
}
if (!result.data) {
@@ -4748,6 +4791,33 @@ async function generateOpenRouterImage(prompt, signal) {
throw new Error(text);
}
async function generateWorkersAIImage(prompt, negativePrompt, signal) {
const result = await fetch('/api/sd/workersai/generate', {
method: 'POST',
headers: getRequestHeaders(),
signal: signal,
body: JSON.stringify({
prompt: prompt,
negative_prompt: negativePrompt,
model: extension_settings.sd.model,
width: extension_settings.sd.width,
height: extension_settings.sd.height,
steps: extension_settings.sd.steps,
scale: extension_settings.sd.scale,
seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
account_id: oai_settings.workers_ai_account_id,
}),
});
if (result.ok) {
const data = await result.json();
return { format: data?.format, data: data?.image };
} else {
const text = await result.text();
throw new Error(text);
}
}
async function onComfyOpenWorkflowEditorClick() {
let workflow = await (await fetch('/api/sd/comfy/workflow', {
method: 'POST',
@@ -5120,6 +5190,8 @@ function isValidState() {
return secret_state[SECRET_KEYS.ZAI];
case sources.openrouter:
return secret_state[SECRET_KEYS.OPENROUTER];
case sources.workersai:
return !!oai_settings.workers_ai_account_id && secret_state[SECRET_KEYS.WORKERS_AI];
default:
return false;
}
@@ -5879,6 +5951,9 @@ export async function init() {
extension_settings.sd.google_duration = Number($(this).val());
saveSettingsDebounced();
});
$('#sd_models_refresh').on('click', async () => {
await loadModels();
});
$('#sd_electronhub_quality').on('change', function () {
extension_settings.sd.electronhub_quality = String($(this).val());
saveSettingsDebounced();
@@ -5922,6 +5997,7 @@ export async function init() {
[sources.aimlapi]: SECRET_KEYS.AIMLAPI,
[sources.comfy]: SECRET_KEYS.COMFY_RUNPOD,
[sources.pollinations]: SECRET_KEYS.POLLINATIONS,
[sources.workersai]: SECRET_KEYS.WORKERS_AI,
};
const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key);
if (!shouldReloadOptions) {
@@ -40,10 +40,11 @@
<span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
</label>
<label for="sd_source" data-i18n="Source">Source</label>
<select id="sd_source">
<select id="sd_source" class="text_pole">
<option value="aimlapi">AI/ML API</option>
<option value="bfl">BFL (Black Forest Labs)</option>
<option value="chutes">Chutes</option>
<option value="workersai">Cloudflare Workers AI</option>
<option value="comfy">ComfyUI</option>
<option value="drawthings">DrawThings HTTP API</option>
<option value="electronhub">Electron Hub</option>
@@ -123,7 +124,7 @@
<div class="flex-container" id="sd_electronhub_quality_row">
<div class="flex1">
<label for="sd_electronhub_quality" data-i18n="Image Quality">Image Quality</label>
<select id="sd_electronhub_quality"></select>
<select id="sd_electronhub_quality" class="text_pole"></select>
</div>
</div>
</div>
@@ -191,14 +192,14 @@
<div class="flex-container">
<div data-sd-model="dall-e-3" class="flex1">
<label for="sd_openai_style" data-i18n="Image Style">Image Style</label>
<select id="sd_openai_style">
<select id="sd_openai_style" class="text_pole">
<option value="vivid">Vivid</option>
<option value="natural">Natural</option>
</select>
</div>
<div data-sd-model="gpt-image" class="flex1">
<label for="sd_openai_quality_gpt" data-i18n="Image Quality">Image Quality</label>
<select id="sd_openai_quality_gpt">
<select id="sd_openai_quality_gpt" class="text_pole">
<option value="auto" data-i18n="Auto">Auto</option>
<option value="low" data-i18n="Low">Low</option>
<option value="medium" data-i18n="Medium">Medium</option>
@@ -207,7 +208,7 @@
</div>
<div data-sd-model="dall-e-3,cogview-4,glm-image,cogvideox" class="flex1">
<label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
<select id="sd_openai_quality">
<select id="sd_openai_quality" class="text_pole">
<option value="standard" data-i18n="Standard">Standard</option>
<option value="hd" data-i18n="HD">HD</option>
</select>
@@ -216,7 +217,7 @@
<div data-sd-model="sora-2,sora-2-pro" class="flex-container">
<div class="flex1">
<label for="sd_openai_duration" data-i18n="Duration">Duration</label>
<select id="sd_openai_duration">
<select id="sd_openai_duration" class="text_pole">
<option value="4" data-i18n="Short (4 seconds)">Short (4 seconds)</option>
<option value="8" data-i18n="Medium (8 seconds)">Medium (8 seconds)</option>
<option value="12" data-i18n="Long (16 seconds)">Long (12 seconds)</option>
@@ -226,7 +227,7 @@
</div>
<div data-sd-source="comfy">
<label for="sd_comfy_type">Server Type</label>
<select id="sd_comfy_type">
<select id="sd_comfy_type" class="text_pole">
<option value="standard">Standard Server</option>
<option value="runpod_serverless">RunPod Serverless Endpoint</option>
</select>
@@ -318,7 +319,7 @@
<div class="flex-container">
<div class="flex1">
<label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label>
<select id="sd_stability_style_preset">
<select id="sd_stability_style_preset" class="text_pole">
<option value="anime">Anime</option>
<option value="3d-model">3D Model</option>
<option value="analog-film">Analog Film</option>
@@ -375,6 +376,20 @@
</div>
</div>
<div data-sd-source="workersai">
<a href="https://dash.cloudflare.com" target="_blank" rel="noopener noreferrer">Cloudflare Workers AI</a>
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<strong class="flex1" data-i18n="API Key">API Key</strong>
<div id="sd_cf_workers_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_workers_ai">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
<div class="flex-container flexnowrap alignItemsBaseline">
<small class="flex1" data-i18n="Hint: Account ID and API key are pulled from API connections.">Hint: Account ID and API key are pulled from API connections.</small>
</div>
</div>
<div data-sd-source="google">
<div class="flex-container">
<div class="flex1">
@@ -394,7 +409,7 @@
</label>
<div class="flex1">
<label for="sd_google_duration" data-i18n="Duration (Veo)">Duration (Veo)</label>
<select id="sd_google_duration">
<select id="sd_google_duration" class="text_pole">
<option value="4">Short (4 seconds)</option>
<option value="6">Medium (6 seconds)</option>
<option value="8">Long (8 seconds)</option>
@@ -405,37 +420,42 @@
<div class="flex-container">
<div class="flex1">
<label for="sd_model" data-i18n="Model">Model</label>
<select id="sd_model"></select>
<label for="sd_model" class="flex-container justifySpaceBetween">
<span data-i18n="Model">Model</span>
<div id="sd_models_refresh" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list">
<i class="fa-solid fa-sync"></i>
</div>
</label>
<select id="sd_model" class="text_pole"></select>
</div>
<div class="flex1" data-sd-source="comfy,auto">
<label for="sd_vae">VAE</label>
<select id="sd_vae"></select>
<select id="sd_vae" class="text_pole"></select>
</div>
</div>
<div class="flex-container">
<div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy,sdcpp">
<label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
<select id="sd_sampler"></select>
<select id="sd_sampler" class="text_pole"></select>
</div>
<div class="flex1" data-sd-source="comfy,auto,novel,sdcpp">
<label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label>
<select id="sd_scheduler"></select>
<select id="sd_scheduler" class="text_pole"></select>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_resolution" data-i18n="Resolution">Resolution</label>
<select id="sd_resolution"><!-- Populated in JS --></select>
<select id="sd_resolution" class="text_pole"><!-- Populated in JS --></select>
</div>
<div class="flex1" data-sd-source="auto,vlad,drawthings">
<label for="sd_hr_upscaler" data-i18n="Upscaler">Upscaler</label>
<select id="sd_hr_upscaler"></select>
<select id="sd_hr_upscaler" class="text_pole"></select>
</div>
</div>
@@ -1,7 +1,3 @@
.sd_settings label:not(.checkbox_label) {
display: block;
}
#sd_dropdown {
z-index: 30000;
backdrop-filter: blur(var(--SmartThemeBlurStrength));
+140 -1
View File
@@ -5,7 +5,6 @@ import express from 'express';
import fetch from 'node-fetch';
import sanitize from 'sanitize-filename';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import FormData from 'form-data';
import urlJoin from 'url-join';
import _ from 'lodash';
import mime from 'mime-types';
@@ -2031,6 +2030,145 @@ zai.post('/generate-video', async (request, response) => {
}
});
const workersai = express.Router();
workersai.post('/models', async (request, response) => {
try {
const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
if (!key) {
console.warn('Cloudflare Workers AI API key not found.');
return response.sendStatus(400);
}
const accountId = String(request.body.account_id || '').trim();
if (!accountId) {
console.warn('Cloudflare Workers AI Account ID not found.');
return response.sendStatus(400);
}
const apiUrl = new URL(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/models/search`);
apiUrl.searchParams.set('task', 'Text-to-Image');
apiUrl.searchParams.set('per_page', '1000');
const result = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${key}`,
},
});
if (!result.ok) {
console.warn('Cloudflare Workers AI returned an error.', result.statusText);
return response.sendStatus(500);
}
/** @type {any} */
const data = await result.json();
if (!data.success || !Array.isArray(data.result)) {
console.warn('Cloudflare Workers AI returned invalid data.');
return response.sendStatus(500);
}
const models = data.result.map(x => ({ value: x.name, text: x.name }));
return response.send(models);
} catch (error) {
console.error(error);
return response.sendStatus(500);
}
});
workersai.post('/generate', async (request, response) => {
try {
const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
if (!key) {
console.warn('Cloudflare Workers AI API key not found.');
return response.sendStatus(400);
}
const accountId = String(request.body.account_id || '').trim();
if (!accountId) {
console.warn('Cloudflare Workers AI Account ID not found.');
return response.sendStatus(400);
}
const model = String(request.body.model || '').trim();
if (!model) {
console.warn('Cloudflare Workers AI model not specified.');
return response.sendStatus(400);
}
const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
const body = {
prompt: request.body.prompt,
negative_prompt: request.body.negative_prompt || undefined,
width: request.body.width ? Number(request.body.width) : undefined,
height: request.body.height ? Number(request.body.height) : undefined,
num_steps: request.body.steps ? Number(request.body.steps) : undefined,
guidance: request.body.scale ? Number(request.body.scale) : undefined,
seed: request.body.seed >= 0 ? Number(request.body.seed) : undefined,
};
// Remove undefined values
for (const prop of Object.keys(body)) {
if (body[prop] === undefined) {
delete body[prop];
}
}
console.debug('Cloudflare Workers AI request:', model, body);
/** @type {import('node-fetch').RequestInit} */
const apiRequest = {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
},
};
if (/flux-2/.test(model)) {
const formData = new FormData();
for (const [key, value] of Object.entries(body)) {
formData.append(key, String(value));
}
apiRequest.body = formData;
} else {
apiRequest.headers = { ...apiRequest.headers, 'Content-Type': 'application/json' };
apiRequest.body = JSON.stringify(body);
}
const result = await fetch(apiUrl, apiRequest);
if (!result.ok) {
const text = await result.text();
console.warn('Cloudflare Workers AI returned an error.', result.status, result.statusText, text);
return response.status(500).send(text);
}
const contentType = result.headers.get('content-type') || '';
// Partner models return JSON with base64 image
if (contentType.includes('application/json')) {
/** @type {any} */
const data = await result.json();
const image = data?.result?.image || data?.image;
if (!image) {
console.warn('Cloudflare Workers AI returned JSON without image data.');
return response.sendStatus(500);
}
return response.send({ format: 'png', image: image });
}
// Non-partner models return raw binary image data
const buffer = await result.arrayBuffer();
return response.send({ format: 'png', image: Buffer.from(buffer).toString('base64') });
} catch (error) {
console.error(error);
return response.sendStatus(500);
}
});
router.use('/comfy', comfy);
router.use('/comfyrunpod', comfyRunPod);
router.use('/together', together);
@@ -2047,3 +2185,4 @@ router.use('/falai', falai);
router.use('/xai', xai);
router.use('/aimlapi', aimlapi);
router.use('/zai', zai);
router.use('/workersai', workersai);