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:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user