Z.AI: Add image generation and web search (#4895) * Z.AI: Add image generation * Z.AI: Add web search endpoint * Add image URL validation * Combine validation conditions

bb3ea08b5a3872d9cb4fcf5dab9fef4b80fcd8be

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +187 -6Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+70 -1
@@ -90,6 +90,7 @@ const sources = {
90 falai: 'falai',90 falai: 'falai',
91 xai: 'xai',91 xai: 'xai',
92 google: 'google',92 google: 'google',
93 zai: 'zai',
93};94};
9495
95const initiators = {96const initiators = {
@@ -1323,6 +1324,7 @@ async function onModelChange() {
1323 sources.xai,1324 sources.xai,
1324 sources.google,1325 sources.google,
1325 sources.chutes,1326 sources.chutes,
1327 sources.zai,
1326 ];1328 ];
13271329
1328 if (cloudSources.includes(extension_settings.sd.source)) {1330 if (cloudSources.includes(extension_settings.sd.source)) {
@@ -1547,12 +1549,18 @@ async function loadSamplers() {
1547 case sources.bfl:1549 case sources.bfl:
1548 samplers = ['N/A'];1550 samplers = ['N/A'];
1549 break;1551 break;
1552 case sources.falai:
1553 samplers = ['N/A'];
1554 break;
1550 case sources.xai:1555 case sources.xai:
1551 samplers = ['N/A'];1556 samplers = ['N/A'];
1552 break;1557 break;
1553 case sources.google:1558 case sources.google:
1554 samplers = ['N/A'];1559 samplers = ['N/A'];
1555 break;1560 break;
1561 case sources.zai:
1562 samplers = ['N/A'];
1563 break;
1556 }1564 }
15571565
1558 for (const sampler of samplers) {1566 for (const sampler of samplers) {
@@ -1758,6 +1766,9 @@ async function loadModels() {
1758 case sources.google:1766 case sources.google:
1759 models = await loadGoogleModels();1767 models = await loadGoogleModels();
1760 break;1768 break;
1769 case sources.zai:
1770 models = await loadZaiModels();
1771 break;
1761 }1772 }
17621773
1763 if (extension_settings.sd.source === sources.electronhub) {1774 if (extension_settings.sd.source === sources.electronhub) {
@@ -2236,6 +2247,10 @@ async function loadGoogleModels() {
2236 ].map(name => ({ value: name, text: name }));2247 ].map(name => ({ value: name, text: name }));
2237}2248}
22382249
2250async function loadZaiModels() {
2251 return ['cogview-4-250304'].map(name => ({ value: name, text: name }));
2252}
2253
2239function loadNovelSchedulers() {2254function loadNovelSchedulers() {
2240 return ['karras', 'native', 'exponential', 'polyexponential'];2255 return ['karras', 'native', 'exponential', 'polyexponential'];
2241}2256}
@@ -2327,6 +2342,9 @@ async function loadSchedulers() {
2327 case sources.google:2342 case sources.google:
2328 schedulers = ['N/A'];2343 schedulers = ['N/A'];
2329 break;2344 break;
2345 case sources.zai:
2346 schedulers = ['N/A'];
2347 break;
2330 }2348 }
23312349
2332 for (const scheduler of schedulers) {2350 for (const scheduler of schedulers) {
@@ -2430,6 +2448,9 @@ async function loadVaes() {
2430 case sources.google:2448 case sources.google:
2431 vaes = ['N/A'];2449 vaes = ['N/A'];
2432 break;2450 break;
2451 case sources.zai:
2452 vaes = ['N/A'];
2453 break;
2433 }2454 }
24342455
2435 for (const vae of vaes) {2456 for (const vae of vaes) {
@@ -2569,7 +2590,7 @@ function processReply(str) {
2569 str = str.normalize('NFD');2590 str = str.normalize('NFD');
25702591
2571 // Strip out non-alphanumeric characters barring model syntax exceptions2592 // Strip out non-alphanumeric characters barring model syntax exceptions
2572 str = str.replace(/[^a-zA-Z0-9.,:_(){}<>[\]\-'|#]+/g, ' ');2593 str = str.replace(/[^a-zA-Z0-9.,:_(){}<>[\]/\-'|#]+/g, ' ');
25732594
2574 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one2595 str = str.replace(/\s+/g, ' '); // Collapse multiple whitespaces into one
2575 str = str.trim();2596 str = str.trim();
@@ -3025,6 +3046,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
3025 case sources.google:3046 case sources.google:
3026 result = await generateGoogleImage(prefixedPrompt, negativePrompt, signal);3047 result = await generateGoogleImage(prefixedPrompt, negativePrompt, signal);
3027 break;3048 break;
3049 case sources.zai:
3050 result = await generateZaiImage(prefixedPrompt, signal);
3051 break;
3028 }3052 }
30293053
3030 if (!result.data) {3054 if (!result.data) {
@@ -4094,6 +4118,47 @@ async function generateGoogleImage(prompt, negativePrompt, signal) {
4094 }4118 }
4095}4119}
40964120
4121/**
4122 * Generates an image using the Z.AI API.
4123 * @param {string} prompt The main instruction used to guide the image generation.
4124 * @param {AbortSignal} signal An AbortSignal object that can be used to cancel the request.
4125 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
4126 */
4127async function generateZaiImage(prompt, signal) {
4128 // Round width and height to nearest multiple of 16, and clamp to 512-2048 range
4129 let width = clamp(Math.round(extension_settings.sd.width / 16) * 16, 512, 2048);
4130 let height = clamp(Math.round(extension_settings.sd.height / 16) * 16, 512, 2048);
4131
4132 // Make sure the pixel count does not exceed 2^21px
4133 while ((width * height) > Math.pow(2, 21)) {
4134 if (width >= height) {
4135 width -= 16;
4136 } else {
4137 height -= 16;
4138 }
4139 }
4140
4141 const result = await fetch('/api/sd/zai/generate', {
4142 method: 'POST',
4143 headers: getRequestHeaders(),
4144 signal: signal,
4145 body: JSON.stringify({
4146 prompt: prompt,
4147 model: extension_settings.sd.model,
4148 quality: extension_settings.sd.openai_quality,
4149 size: `${width}x${height}`,
4150 }),
4151 });
4152
4153 if (result.ok) {
4154 const data = await result.json();
4155 return { format: data.format, data: data.image };
4156 }
4157
4158 const text = await result.text();
4159 throw new Error(text);
4160}
4161
4097async function onComfyOpenWorkflowEditorClick() {4162async function onComfyOpenWorkflowEditorClick() {
4098 let workflow = await (await fetch('/api/sd/comfy/workflow', {4163 let workflow = await (await fetch('/api/sd/comfy/workflow', {
4099 method: 'POST',4164 method: 'POST',
@@ -4400,6 +4465,10 @@ function isValidState() {
4400 return secret_state[SECRET_KEYS.XAI];4465 return secret_state[SECRET_KEYS.XAI];
4401 case sources.google:4466 case sources.google:
4402 return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT];4467 return secret_state[SECRET_KEYS.MAKERSUITE] || secret_state[SECRET_KEYS.VERTEXAI] || secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT];
4468 case sources.zai:
4469 return secret_state[SECRET_KEYS.ZAI];
4470 default:
4471 return false;
4403 }4472 }
4404}4473}
44054474
public/scripts/extensions/stable-diffusion/settings.html+8 -4
@@ -57,6 +57,7 @@
57 <option value="horde">Stable Horde</option>57 <option value="horde">Stable Horde</option>
58 <option value="togetherai">TogetherAI</option>58 <option value="togetherai">TogetherAI</option>
59 <option value="xai">xAI (Grok)</option>59 <option value="xai">xAI (Grok)</option>
60 <option value="zai">Z.AI (CogView)</option>
60 </select>61 </select>
61 <div data-sd-source="auto">62 <div data-sd-source="auto">
62 <label for="sd_auto_url">SD Web UI URL</label>63 <label for="sd_auto_url">SD Web UI URL</label>
@@ -164,16 +165,19 @@
164 </div>165 </div>
165 </div>166 </div>
166 </div>167 </div>
167 <div data-sd-source="openai,aimlapi">168 <div data-sd-source="zai">
168 <div data-sd-model="dall-e-3" class="flex-container">169 <b>Will use Common API. Coding API is not supported!</b>
169 <div class="flex1">170 </div>
171 <div data-sd-source="openai,aimlapi,zai">
172 <div class="flex-container">
173 <div data-sd-model="dall-e-3" class="flex1">
170 <label for="sd_openai_style" data-i18n="Image Style">Image Style</label>174 <label for="sd_openai_style" data-i18n="Image Style">Image Style</label>
171 <select id="sd_openai_style">175 <select id="sd_openai_style">
172 <option value="vivid">Vivid</option>176 <option value="vivid">Vivid</option>
173 <option value="natural">Natural</option>177 <option value="natural">Natural</option>
174 </select>178 </select>
175 </div>179 </div>
176 <div class="flex1">180 <div data-sd-model="dall-e-3,cogview-4-250304" class="flex1">
177 <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>181 <label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
178 <select id="sd_openai_quality">182 <select id="sd_openai_quality">
179 <option value="standard" data-i18n="Standard">Standard</option>183 <option value="standard" data-i18n="Standard">Standard</option>
src/endpoints/openai.js+1 -0
@@ -91,6 +91,7 @@ router.post('/caption-image', async (request, response) => {
9191
92 if (request.body.api === 'zai') {92 if (request.body.api === 'zai') {
93 key = readSecret(request.user.directories, SECRET_KEYS.ZAI);93 key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
94 bodyParams.max_tokens = 4096; // default is 1024
94 }95 }
9596
96 const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp', 'pollinations'];97 const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp', 'pollinations'];
src/endpoints/search.js+46 -0
@@ -341,6 +341,52 @@ router.post('/serper', async (request, response) => {
341 }341 }
342});342});
343343
344router.post('/zai', async (request, response) => {
345 try {
346 const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
347
348 if (!key) {
349 console.error('No Z.AI key found');
350 return response.sendStatus(400);
351 }
352
353 const { query } = request.body;
354
355 if (!query) {
356 console.error('No query provided for /zai');
357 return response.sendStatus(400);
358 }
359
360 console.debug('Z.AI web search query', query);
361
362 const result = await fetch('https://api.z.ai/api/paas/v4/web_search', {
363 method: 'POST',
364 headers: {
365 'Content-Type': 'application/json',
366 'Authorization': `Bearer ${key}`,
367 },
368 body: JSON.stringify({
369 // TODO: There's only one engine option for now
370 search_engine: 'search-prime',
371 search_query: query,
372 }),
373 });
374
375 if (!result.ok) {
376 const text = await result.text();
377 console.error('Z.AI request failed', result.statusText, text);
378 return response.status(500).send(text);
379 }
380
381 const data = await result.json();
382 console.debug('Z.AI web search response', data);
383 return response.json(data);
384 } catch (error) {
385 console.error(error);
386 return response.sendStatus(500);
387 }
388});
389
344router.post('/visit', async (request, response) => {390router.post('/visit', async (request, response) => {
345 try {391 try {
346 const url = request.body.url;392 const url = request.body.url;
src/endpoints/stable-diffusion.js+62 -1
@@ -9,7 +9,7 @@ import FormData from 'form-data';
9import urlJoin from 'url-join';9import urlJoin from 'url-join';
10import _ from 'lodash';10import _ from 'lodash';
1111
12import { delay, getBasicAuthHeader, tryParse } from '../util.js';12import { delay, getBasicAuthHeader, isValidUrl, tryParse } from '../util.js';
13import { readSecret, SECRET_KEYS } from './secrets.js';13import { readSecret, SECRET_KEYS } from './secrets.js';
14import { AIMLAPI_HEADERS } from '../constants.js';14import { AIMLAPI_HEADERS } from '../constants.js';
1515
@@ -1643,6 +1643,66 @@ aimlapi.post('/generate-image', async (req, res) => {
1643 }1643 }
1644});1644});
16451645
1646const zai = express.Router();
1647
1648zai.post('/generate', async (request, response) => {
1649 try {
1650 const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
1651
1652 if (!key) {
1653 console.warn('Z.AI key not found.');
1654 return response.sendStatus(400);
1655 }
1656
1657 console.debug('Z.AI image request:', request.body);
1658
1659 const generateResponse = await fetch('https://api.z.ai/api/paas/v4/images/generations', {
1660 method: 'POST',
1661 headers: {
1662 'Content-Type': 'application/json',
1663 'Authorization': `Bearer ${key}`,
1664 },
1665 body: JSON.stringify({
1666 prompt: request.body.prompt,
1667 model: request.body.model,
1668 quality: request.body.quality,
1669 size: request.body.size,
1670 }),
1671 });
1672
1673 if (!generateResponse.ok) {
1674 const text = await generateResponse.text();
1675 console.warn('Z.AI returned an error.', text);
1676 return response.sendStatus(500);
1677 }
1678
1679 /** @type {any} */
1680 const data = await generateResponse.json();
1681 console.debug('Z.AI image response:', data);
1682
1683 const url = data?.data?.[0]?.url;
1684 if (!url || !isValidUrl(url) || !new URL(url).hostname.endsWith('.z.ai')) {
1685 console.warn('Z.AI returned an invalid image URL.');
1686 return response.sendStatus(500);
1687 }
1688
1689 const imageResponse = await fetch(url);
1690 if (!imageResponse.ok) {
1691 console.warn('Z.AI image fetch returned an error.');
1692 return response.sendStatus(500);
1693 }
1694
1695 const buffer = await imageResponse.arrayBuffer();
1696 const image = Buffer.from(buffer).toString('base64');
1697 const format = path.extname(url).substring(1).toLowerCase() || 'png';
1698
1699 return response.send({ image, format });
1700 } catch (error) {
1701 console.error(error);
1702 return response.sendStatus(500);
1703 }
1704});
1705
1646router.use('/comfy', comfy);1706router.use('/comfy', comfy);
1647router.use('/together', together);1707router.use('/together', together);
1648router.use('/drawthings', drawthings);1708router.use('/drawthings', drawthings);
@@ -1656,3 +1716,4 @@ router.use('/bfl', bfl);
1656router.use('/falai', falai);1716router.use('/falai', falai);
1657router.use('/xai', xai);1717router.use('/xai', xai);
1658router.use('/aimlapi', aimlapi);1718router.use('/aimlapi', aimlapi);
1719router.use('/zai', zai);