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>

78628f7dbb7c2f0d1cb33cac655994ab87a91903

Copilot <198982749+Copilot@users.noreply.github.com>

Signed
4 files changed, +252 -21Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+76 -0
@@ -99,6 +99,7 @@ const sources = {
9999 google: 'google',
100100 zai: 'zai',
101101 openrouter: 'openrouter',
102+ workersai: 'workersai',
102103};
103104const comfyTypes = {
104105 standard: 'standard',
@@ -1747,6 +1748,9 @@ async function loadSamplers() {
17471748 case sources.openrouter:
17481749 samplers = ['N/A'];
17491750 break;
1751+ case sources.workersai:
1752+ samplers = ['N/A'];
1753+ break;
17501754 }
17511755
17521756 for (const sampler of samplers) {
@@ -1997,6 +2001,9 @@ async function loadModels() {
19972001 case sources.openrouter:
19982002 models = await loadOpenRouterModels();
19992003 break;
2004+ case sources.workersai:
2005+ models = await loadWorkersAIImageModels();
2006+ break;
20002007 }
20012008
20022009 if (extension_settings.sd.source === sources.electronhub) {
@@ -2124,6 +2131,33 @@ async function loadXAIModels() {
21242131 ];
21252132}
21262133
2134+async function loadWorkersAIImageModels() {
2135+ $('#sd_cf_workers_key').toggleClass('success', !!secret_state[SECRET_KEYS.WORKERS_AI]);
2136+
2137+ if (!secret_state[SECRET_KEYS.WORKERS_AI]) {
2138+ return [];
2139+ }
2140+
2141+ if (!oai_settings.workers_ai_account_id) {
2142+ toastr.warning('Workers AI account ID is required. Save it in the "API Connections" panel.', 'Image Generation');
2143+ return [];
2144+ }
2145+
2146+ const result = await fetch('/api/sd/workersai/models', {
2147+ method: 'POST',
2148+ headers: getRequestHeaders(),
2149+ body: JSON.stringify({
2150+ account_id: oai_settings.workers_ai_account_id,
2151+ }),
2152+ });
2153+
2154+ if (result.ok) {
2155+ return await result.json();
2156+ }
2157+
2158+ return [];
2159+}
2160+
21272161async function loadPollinationsModels() {
21282162 $('#sd_pollinations_key').toggleClass('success', !!secret_state[SECRET_KEYS.POLLINATIONS]);
21292163
@@ -2609,6 +2643,9 @@ async function loadSchedulers() {
26092643 case sources.openrouter:
26102644 schedulers = ['N/A'];
26112645 break;
2646+ case sources.workersai:
2647+ schedulers = ['N/A'];
2648+ break;
26122649 }
26132650
26142651 for (const scheduler of schedulers) {
@@ -2729,6 +2766,9 @@ async function loadVaes() {
27292766 case sources.openrouter:
27302767 vaes = ['N/A'];
27312768 break;
2769+ case sources.workersai:
2770+ vaes = ['N/A'];
2771+ break;
27322772 }
27332773
27342774 for (const vae of vaes) {
@@ -3432,6 +3472,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
34323472 case sources.openrouter:
34333473 result = await generateOpenRouterImage(prefixedPrompt, signal);
34343474 break;
3475+ case sources.workersai:
3476+ result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signal);
3477+ break;
34353478 }
34363479
34373480 if (!result.data) {
@@ -4748,6 +4791,33 @@ async function generateOpenRouterImage(prompt, signal) {
47484791 throw new Error(text);
47494792}
47504793
4794+async function generateWorkersAIImage(prompt, negativePrompt, signal) {
4795+ const result = await fetch('/api/sd/workersai/generate', {
4796+ method: 'POST',
4797+ headers: getRequestHeaders(),
4798+ signal: signal,
4799+ body: JSON.stringify({
4800+ prompt: prompt,
4801+ negative_prompt: negativePrompt,
4802+ model: extension_settings.sd.model,
4803+ width: extension_settings.sd.width,
4804+ height: extension_settings.sd.height,
4805+ steps: extension_settings.sd.steps,
4806+ scale: extension_settings.sd.scale,
4807+ seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
4808+ account_id: oai_settings.workers_ai_account_id,
4809+ }),
4810+ });
4811+
4812+ if (result.ok) {
4813+ const data = await result.json();
4814+ return { format: data?.format, data: data?.image };
4815+ } else {
4816+ const text = await result.text();
4817+ throw new Error(text);
4818+ }
4819+}
4820+
47514821async function onComfyOpenWorkflowEditorClick() {
47524822 let workflow = await (await fetch('/api/sd/comfy/workflow', {
47534823 method: 'POST',
@@ -5120,6 +5190,8 @@ function isValidState() {
51205190 return secret_state[SECRET_KEYS.ZAI];
51215191 case sources.openrouter:
51225192 return secret_state[SECRET_KEYS.OPENROUTER];
5193+ case sources.workersai:
5194+ return !!oai_settings.workers_ai_account_id && secret_state[SECRET_KEYS.WORKERS_AI];
51235195 default:
51245196 return false;
51255197 }
@@ -5879,6 +5951,9 @@ export async function init() {
58795951 extension_settings.sd.google_duration = Number($(this).val());
58805952 saveSettingsDebounced();
58815953 });
5954+ $('#sd_models_refresh').on('click', async () => {
5955+ await loadModels();
5956+ });
58825957 $('#sd_electronhub_quality').on('change', function () {
58835958 extension_settings.sd.electronhub_quality = String($(this).val());
58845959 saveSettingsDebounced();
@@ -5922,6 +5997,7 @@ export async function init() {
59225997 [sources.aimlapi]: SECRET_KEYS.AIMLAPI,
59235998 [sources.comfy]: SECRET_KEYS.COMFY_RUNPOD,
59245999 [sources.pollinations]: SECRET_KEYS.POLLINATIONS,
6000+ [sources.workersai]: SECRET_KEYS.WORKERS_AI,
59256001 };
59266002 const shouldReloadOptions = Object.entries(keySourceMap).some(([k, v]) => k === extension_settings.sd.source && v === key);
59276003 if (!shouldReloadOptions) {
public/scripts/extensions/stable-diffusion/settings.html+36 -16
@@ -40,10 +40,11 @@
4040 <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span>
4141 </label>
4242 <label for="sd_source" data-i18n="Source">Source</label>
4343 <select id="sd_source" class="text_pole">
4444 <option value="aimlapi">AI/ML API</option>
4545 <option value="bfl">BFL (Black Forest Labs)</option>
4646 <option value="chutes">Chutes</option>
47+ <option value="workersai">Cloudflare Workers AI</option>
4748 <option value="comfy">ComfyUI</option>
4849 <option value="drawthings">DrawThings HTTP API</option>
4950 <option value="electronhub">Electron Hub</option>
@@ -123,7 +124,7 @@
123124 <div class="flex-container" id="sd_electronhub_quality_row">
124125 <div class="flex1">
125126 <label for="sd_electronhub_quality" data-i18n="Image Quality">Image Quality</label>
126127 <select id="sd_electronhub_quality" class="text_pole"></select>
127128 </div>
128129 </div>
129130 </div>
@@ -191,14 +192,14 @@
191192 <div class="flex-container">
192193 <div data-sd-model="dall-e-3" class="flex1">
193194 <label for="sd_openai_style" data-i18n="Image Style">Image Style</label>
194195 <select id="sd_openai_style" class="text_pole">
195196 <option value="vivid">Vivid</option>
196197 <option value="natural">Natural</option>
197198 </select>
198199 </div>
199200 <div data-sd-model="gpt-image" class="flex1">
200201 <label for="sd_openai_quality_gpt" data-i18n="Image Quality">Image Quality</label>
201202 <select id="sd_openai_quality_gpt" class="text_pole">
202203 <option value="auto" data-i18n="Auto">Auto</option>
203204 <option value="low" data-i18n="Low">Low</option>
204205 <option value="medium" data-i18n="Medium">Medium</option>
@@ -207,7 +208,7 @@
207208 </div>
208209 <div data-sd-model="dall-e-3,cogview-4,glm-image,cogvideox" class="flex1">
209210 <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
210211 <select id="sd_openai_quality" class="text_pole">
211212 <option value="standard" data-i18n="Standard">Standard</option>
212213 <option value="hd" data-i18n="HD">HD</option>
213214 </select>
@@ -216,7 +217,7 @@
216217 <div data-sd-model="sora-2,sora-2-pro" class="flex-container">
217218 <div class="flex1">
218219 <label for="sd_openai_duration" data-i18n="Duration">Duration</label>
219220 <select id="sd_openai_duration" class="text_pole">
220221 <option value="4" data-i18n="Short (4 seconds)">Short (4 seconds)</option>
221222 <option value="8" data-i18n="Medium (8 seconds)">Medium (8 seconds)</option>
222223 <option value="12" data-i18n="Long (16 seconds)">Long (12 seconds)</option>
@@ -226,7 +227,7 @@
226227 </div>
227228 <div data-sd-source="comfy">
228229 <label for="sd_comfy_type">Server Type</label>
229230 <select id="sd_comfy_type" class="text_pole">
230231 <option value="standard">Standard Server</option>
231232 <option value="runpod_serverless">RunPod Serverless Endpoint</option>
232233 </select>
@@ -318,7 +319,7 @@
318319 <div class="flex-container">
319320 <div class="flex1">
320321 <label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label>
321322 <select id="sd_stability_style_preset" class="text_pole">
322323 <option value="anime">Anime</option>
323324 <option value="3d-model">3D Model</option>
324325 <option value="analog-film">Analog Film</option>
@@ -375,6 +376,20 @@
375376 </div>
376377 </div>
377378
379+ <div data-sd-source="workersai">
380+ <a href="https://dash.cloudflare.com" target="_blank" rel="noopener noreferrer">Cloudflare Workers AI</a>
381+ <div class="flex-container flexnowrap alignItemsBaseline marginBot5">
382+ <strong class="flex1" data-i18n="API Key">API Key</strong>
383+ <div id="sd_cf_workers_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_workers_ai">
384+ <i class="fa-fw fa-solid fa-key"></i>
385+ <span data-i18n="Click to set">Click to set</span>
386+ </div>
387+ </div>
388+ <div class="flex-container flexnowrap alignItemsBaseline">
389+ <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>
390+ </div>
391+ </div>
392+
378393 <div data-sd-source="google">
379394 <div class="flex-container">
380395 <div class="flex1">
@@ -394,7 +409,7 @@
394409 </label>
395410 <div class="flex1">
396411 <label for="sd_google_duration" data-i18n="Duration (Veo)">Duration (Veo)</label>
397412 <select id="sd_google_duration" class="text_pole">
398413 <option value="4">Short (4 seconds)</option>
399414 <option value="6">Medium (6 seconds)</option>
400415 <option value="8">Long (8 seconds)</option>
@@ -405,37 +420,42 @@
405420
406421 <div class="flex-container">
407422 <div class="flex1">
408423 <label for="sd_model" data-i18nclass="Modelflex-container justifySpaceBetween">Model</label>
409424 <selectspan iddata-i18n="sd_modelModel">Model</selectspan>
425+ <div id="sd_models_refresh" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list">
426+ <i class="fa-solid fa-sync"></i>
427+ </div>
428+ </label>
429+ <select id="sd_model" class="text_pole"></select>
410430 </div>
411431
412432 <div class="flex1" data-sd-source="comfy,auto">
413433 <label for="sd_vae">VAE</label>
414434 <select id="sd_vae" class="text_pole"></select>
415435 </div>
416436 </div>
417437
418438 <div class="flex-container">
419439 <div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy,sdcpp">
420440 <label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
421441 <select id="sd_sampler" class="text_pole"></select>
422442 </div>
423443
424444 <div class="flex1" data-sd-source="comfy,auto,novel,sdcpp">
425445 <label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label>
426446 <select id="sd_scheduler" class="text_pole"></select>
427447 </div>
428448 </div>
429449
430450 <div class="flex-container">
431451 <div class="flex1">
432452 <label for="sd_resolution" data-i18n="Resolution">Resolution</label>
433453 <select id="sd_resolution" class="text_pole"><!-- Populated in JS --></select>
434454 </div>
435455
436456 <div class="flex1" data-sd-source="auto,vlad,drawthings">
437457 <label for="sd_hr_upscaler" data-i18n="Upscaler">Upscaler</label>
438458 <select id="sd_hr_upscaler" class="text_pole"></select>
439459 </div>
440460 </div>
441461
public/scripts/extensions/stable-diffusion/style.css+0 -4
@@ -1,7 +1,3 @@
1-.sd_settings label:not(.checkbox_label) {
2- display: block;
3-}
4-
51#sd_dropdown {
62 z-index: 30000;
73 backdrop-filter: blur(var(--SmartThemeBlurStrength));
src/endpoints/stable-diffusion.js+140 -1
@@ -5,7 +5,6 @@ import express from 'express';
55import fetch from 'node-fetch';
66import sanitize from 'sanitize-filename';
77import { sync as writeFileAtomicSync } from 'write-file-atomic';
8-import FormData from 'form-data';
98import urlJoin from 'url-join';
109import _ from 'lodash';
1110import mime from 'mime-types';
@@ -2031,6 +2030,145 @@ zai.post('/generate-video', async (request, response) => {
20312030 }
20322031});
20332032
2033+const workersai = express.Router();
2034+
2035+workersai.post('/models', async (request, response) => {
2036+ try {
2037+ const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
2038+
2039+ if (!key) {
2040+ console.warn('Cloudflare Workers AI API key not found.');
2041+ return response.sendStatus(400);
2042+ }
2043+
2044+ const accountId = String(request.body.account_id || '').trim();
2045+ if (!accountId) {
2046+ console.warn('Cloudflare Workers AI Account ID not found.');
2047+ return response.sendStatus(400);
2048+ }
2049+
2050+ const apiUrl = new URL(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/models/search`);
2051+ apiUrl.searchParams.set('task', 'Text-to-Image');
2052+ apiUrl.searchParams.set('per_page', '1000');
2053+ const result = await fetch(apiUrl, {
2054+ method: 'GET',
2055+ headers: {
2056+ 'Authorization': `Bearer ${key}`,
2057+ },
2058+ });
2059+
2060+ if (!result.ok) {
2061+ console.warn('Cloudflare Workers AI returned an error.', result.statusText);
2062+ return response.sendStatus(500);
2063+ }
2064+
2065+ /** @type {any} */
2066+ const data = await result.json();
2067+
2068+ if (!data.success || !Array.isArray(data.result)) {
2069+ console.warn('Cloudflare Workers AI returned invalid data.');
2070+ return response.sendStatus(500);
2071+ }
2072+
2073+ const models = data.result.map(x => ({ value: x.name, text: x.name }));
2074+ return response.send(models);
2075+ } catch (error) {
2076+ console.error(error);
2077+ return response.sendStatus(500);
2078+ }
2079+});
2080+
2081+workersai.post('/generate', async (request, response) => {
2082+ try {
2083+ const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
2084+
2085+ if (!key) {
2086+ console.warn('Cloudflare Workers AI API key not found.');
2087+ return response.sendStatus(400);
2088+ }
2089+
2090+ const accountId = String(request.body.account_id || '').trim();
2091+ if (!accountId) {
2092+ console.warn('Cloudflare Workers AI Account ID not found.');
2093+ return response.sendStatus(400);
2094+ }
2095+
2096+ const model = String(request.body.model || '').trim();
2097+ if (!model) {
2098+ console.warn('Cloudflare Workers AI model not specified.');
2099+ return response.sendStatus(400);
2100+ }
2101+
2102+ const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
2103+
2104+ const body = {
2105+ prompt: request.body.prompt,
2106+ negative_prompt: request.body.negative_prompt || undefined,
2107+ width: request.body.width ? Number(request.body.width) : undefined,
2108+ height: request.body.height ? Number(request.body.height) : undefined,
2109+ num_steps: request.body.steps ? Number(request.body.steps) : undefined,
2110+ guidance: request.body.scale ? Number(request.body.scale) : undefined,
2111+ seed: request.body.seed >= 0 ? Number(request.body.seed) : undefined,
2112+ };
2113+
2114+ // Remove undefined values
2115+ for (const prop of Object.keys(body)) {
2116+ if (body[prop] === undefined) {
2117+ delete body[prop];
2118+ }
2119+ }
2120+
2121+ console.debug('Cloudflare Workers AI request:', model, body);
2122+
2123+ /** @type {import('node-fetch').RequestInit} */
2124+ const apiRequest = {
2125+ method: 'POST',
2126+ headers: {
2127+ 'Authorization': `Bearer ${key}`,
2128+ },
2129+ };
2130+
2131+ if (/flux-2/.test(model)) {
2132+ const formData = new FormData();
2133+ for (const [key, value] of Object.entries(body)) {
2134+ formData.append(key, String(value));
2135+ }
2136+ apiRequest.body = formData;
2137+ } else {
2138+ apiRequest.headers = { ...apiRequest.headers, 'Content-Type': 'application/json' };
2139+ apiRequest.body = JSON.stringify(body);
2140+ }
2141+
2142+ const result = await fetch(apiUrl, apiRequest);
2143+ if (!result.ok) {
2144+ const text = await result.text();
2145+ console.warn('Cloudflare Workers AI returned an error.', result.status, result.statusText, text);
2146+ return response.status(500).send(text);
2147+ }
2148+
2149+ const contentType = result.headers.get('content-type') || '';
2150+
2151+ // Partner models return JSON with base64 image
2152+ if (contentType.includes('application/json')) {
2153+ /** @type {any} */
2154+ const data = await result.json();
2155+ const image = data?.result?.image || data?.image;
2156+ if (!image) {
2157+ console.warn('Cloudflare Workers AI returned JSON without image data.');
2158+ return response.sendStatus(500);
2159+ }
2160+ return response.send({ format: 'png', image: image });
2161+ }
2162+
2163+ // Non-partner models return raw binary image data
2164+ const buffer = await result.arrayBuffer();
2165+ return response.send({ format: 'png', image: Buffer.from(buffer).toString('base64') });
2166+ } catch (error) {
2167+ console.error(error);
2168+ return response.sendStatus(500);
2169+ }
2170+});
2171+
20342172router.use('/comfy', comfy);
20352173router.use('/comfyrunpod', comfyRunPod);
20362174router.use('/together', together);
@@ -2047,3 +2185,4 @@ router.use('/falai', falai);
20472185router.use('/xai', xai);
20482186router.use('/aimlapi', aimlapi);
20492187router.use('/zai', zai);
2188+router.use('/workersai', workersai);