diff --git a/src/services/apis/bing-web.mjs b/src/services/apis/bing-web.mjs index 4abbacd..00695c7 100644 --- a/src/services/apis/bing-web.mjs +++ b/src/services/apis/bing-web.mjs @@ -24,7 +24,7 @@ export async function generateAnswersWithBingWebApi( console.debug('mode', modelMode) - const bingAIClient = new BingAIClient({ userToken: accessToken }) + const bingAIClient = new BingAIClient({ userToken: accessToken, features: { genImage: false } }) if (session.bingWeb_jailbreakConversationCache) bingAIClient.conversationsCache.set( session.bingWeb_jailbreakConversationId, diff --git a/src/services/apis/custom-api.mjs b/src/services/apis/custom-api.mjs index 22952cb..d85dd25 100644 --- a/src/services/apis/custom-api.mjs +++ b/src/services/apis/custom-api.mjs @@ -60,9 +60,8 @@ export async function generateAnswersWithCustomApi(port, question, session, apiK console.debug('json error', error) return } - - if (data.response) - answer = data.response + + if (data.response) answer = data.response else answer += data.choices[0]?.delta?.content || diff --git a/src/services/clients/bing/BingImageCreator.js b/src/services/clients/bing/BingImageCreator.js new file mode 100644 index 0000000..214a74c --- /dev/null +++ b/src/services/clients/bing/BingImageCreator.js @@ -0,0 +1,512 @@ +export default class BingImageCreator { + /** + * @constructor + * @param {Object} options - Options for BingImageCreator. + */ + constructor(options) { + this.setOptions(options) + } + + /** + * Set options for BingImageCreator. + * @param {Object} options - Options for BingImageCreator. The format of the options is almost same as the bingAiClient options of 'node-chatgpt-api'. + */ + setOptions(options) { + if (this.options && !this.options.replaceOptions) { + this.options = { + ...this.options, + ...options, + } + } else { + this.options = { + ...options, + host: options.host || 'https://www.bing.com', + apipath: options.apipath || '/images/create?partner=sydney&re=1&showselective=1&sude=1', + ua: + options.ua || + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.35', + xForwardedFor: this.constructor.getValidIPv4(options.xForwardedFor), + features: { + enableAnsCardSfx: true, + }, + enableTelemetry: true, + telemetry: { + eventID: 'Codex', + instrumentedLinkName: 'CodexInstLink', + externalLinkName: 'CodexInstExtLink', + kSeedBase: 6500, + kSeedIncrement: 500, + instSuffix: 0, + instSuffixIncrement: 1, + }, + } + } + this.apiurl = `${this.options.host}${this.options.apipath}` + this.telemetry = { + config: this.options, + currentKSeed: this.options.telemetry.kSeedBase, + instSuffix: this.options.telemetry.instSuffix, + getNextKSeed() { + // eslint-disable-next-line no-return-assign, no-sequences + return (this.currentKSeed += this.config.telemetry.kSeedIncrement), this.currentKSeed + }, + getNextInstSuffix() { + // eslint-disable-next-line no-return-assign + return this.config.features.enableAnsCardSfx + ? ((this.instSuffix += this.config.telemetry.instSuffixIncrement), + this.instSuffix > 1 ? `${this.instSuffix}` : '') + : '' + }, + } + this.debug = this.options.debug + } + + /** + * Get a valid IPv4 address string from input IP. + * @param {string} ip - A fixed IPv4 address or a range of IPv4 using CIDR notation. + * @returns {string} A valid IPv4 address or undefined. + * If 'ip' is a valid fixed IPv4 address, it returns 'ip' itself. + * If 'ip' is a range of IPv4 using CIDR notation, it returns a random address within the range. + * Otherwise, it returns undefined. + */ + static getValidIPv4(ip) { + const match = + !ip || + ip.match( + /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([0-9]|[1-2][0-9]|3[0-2]))?$/, + ) + if (match) { + if (match[5]) { + const mask = parseInt(match[5], 10) + let [a, b, c, d] = ip.split('.').map((x) => parseInt(x, 10)) + // eslint-disable-next-line no-bitwise + const max = (1 << (32 - mask)) - 1 + const rand = Math.floor(Math.random() * max) + d += rand + c += Math.floor(d / 256) + d %= 256 + b += Math.floor(c / 256) + c %= 256 + a += Math.floor(b / 256) + b %= 256 + return `${a}.${b}.${c}.${d}` + } + return ip + } + return undefined + } + + /** + * Get fetchOptions of BingImageCreator. + * {Object} The fetch options used for BingImageCreator. + */ + get fetchOptions() { + let fetchOptions + return ( + this.options.fetchOptions ?? + (() => { + if (!fetchOptions) { + fetchOptions = { + headers: { + accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'accept-language': 'en-US,en;q=0.9', + 'cache-control': 'no-cache', + 'sec-ch-ua': '"Microsoft Edge";v="113", "Chromium";v="113", "Not-A.Brand";v="24"', + 'sec-ch-ua-arch': '"x86"', + 'sec-ch-ua-bitness': '"64"', + 'sec-ch-ua-full-version': '"113.0.1774.35"', + 'sec-ch-ua-full-version-list': + '"Microsoft Edge";v="113.0.1774.35", "Chromium";v="113.0.5672.63", "Not-A.Brand";v="24.0.0.0"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-model': '""', + 'sec-ch-ua-platform': '"Windows"', + 'sec-ch-ua-platform-version': '"11.0.0"', + 'sec-fetch-dest': 'iframe', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-site': 'same-origin', + cookie: + this.options.cookies || + (this.options.userToken ? `_U=${this.options.userToken}` : undefined), + pragma: 'no-cache', + referer: 'https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx', + 'Referrer-Policy': 'origin-when-cross-origin', + // Workaround for request being blocked due to geolocation + ...(this.options.xForwardedFor + ? { 'x-forwarded-for': this.options.xForwardedFor } + : {}), + 'upgrade-insecure-requests': '1', + 'user-agent': this.options.ua, + 'x-edge-shopping-flag': '1', + }, + } + + if (this.options.proxy) { + // fetchOptions.dispatcher = new ProxyAgent(this.options.proxy); + } + } + + return fetchOptions + })() + ) + } + + /** + * Decode the HTML entities, a very lite version. + * @param {string} html - The HTML string to be decoded. + * @returns {string} Decoded string. + */ + static decodeHtmlLite(html) { + const entities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ' ': String.fromCharCode(160), + } + return html.replace(/&[a-z]+;/g, (match) => entities[match] || match) + } + + /** + * Removes a specific HTML element and its corresponding closing tag from a web page string. + * @param {string} html - The web page string to be processed. + * @param {string} tag - The element tag to be removed, such as 'div'. + * @param {string} tagId - The id of the element to be removed, such as 'giloader'. + * @returns {string} A new web page string with the specified element and its closing tag removed. + */ + static removeHtmlTagLite(html, tag, tagId) { + // Create a regex, matches , id can be at any available position. + const regex = new RegExp(`<${tag}[^>]*id="${tagId}"[^>]*>`) + + // Find out the start and end position of . + const match = regex.exec(html) + + // return the original html if nothing matches. + if (!match) { + return html + } + + const start = match.index + let end = match.index + match[0].length + + // Count the nested tags, the initial value is 0. + let nested = 0 + let i = end + let s = i - 1 + let e = s + const tagStart = `<${tag} ` + const tagEnd = `` + + // loop the string, until find out its matched ''. + while (e > 0) { + if (e < i) { + e = html.indexOf(tagEnd, i) + } + if (e > 0) { + if (s > 0 && s < i) { + s = html.indexOf(tagStart, i) + } + if (s > 0) { + i = Math.min(s, e) + nested += i === s ? ((i += tagStart.length), 1) : ((i += tagEnd.length), -1) + } else { + i = e + tagEnd.length + nested -= 1 + } + // If nested is -1, the matched '' is found. + if (nested === -1) { + // Update the end position, make it point to the position after . + end = i + // Break the loop; + break + } + } + } + + // Remove the strings between the '' and the matched ''. + return html.slice(0, start) + html.slice(end) + } + + /** + * Delay the execution for a given time in millisecond unit. + * @param {number} ms - The time to be delayed in millisecond unit. + * @returns {Promise} A promise object that is used to wait. + */ + static sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * @typedef {Object} BicCreationResult + * @property {string} contentUrl - A URL pointing to the creation page. + * @property {string} pollingUrl - The URL to poll the image creation request. + * @property {string} contentHtml - The source code of the creation page. + * @property {string} prompt - The prompt for the image generation. + * @property {string} iframeid - The message ID refers to the image generation. + */ + + /** + * Use BIC to generate images according to the given prompt and message ID. + * @param {string} prompt - The prompt for the image generation. It should be given by 'Sydney'. + * @param {string} messageId - The message ID refers to the message of 'Sydney'. + * @returns {BicCreationResult} A BicCreationResult object that contains the result of the creation. + */ + async genImagePage(prompt, messageId) { + let telemetryData = '' + if (this.options.enableTelemetry) { + telemetryData = `&kseed=${this.telemetry.getNextKSeed()}&SFX=${this.telemetry.getNextInstSuffix()}` + } + + // https://www.bing.com/images/create?partner=sydney&re=1&showselective=1&sude=1&kseed=8000&SFX=3&q=${encodeURIComponent(prompt)}&iframeid=${messageId} + const url = `${this.apiurl}${telemetryData}&q=${encodeURIComponent(prompt)}${ + messageId ? `&iframeid=${messageId}` : '' + }` + + if (this.debug) { + console.debug(`The url of the request for image creation: ${url}`) + console.debug() + } + + const response = await fetch(url, this.fetchOptions) + const { status } = response + if (this.debug) { + console.debug('The response of the request for image creation:') + console.debug(response) + console.debug() + } + + if (status !== 200) { + throw new Error(`Bing Image Creator Error: response status = ${status}`) + } + + const body = await response.text() + let regex = /
(.*?)<\/div>/ + const err = regex.exec(body)?.[1] + throw new Error(`Bing Image Creator Error: ${err}`) + } + + return { + contentUrl: `${response.url}`, + pollingUrl: `${this.options.host}${this.constructor.decodeHtmlLite(pollingUrl)}`, + contentHtml: body, + prompt: `${prompt}`, + iframeid: `${messageId}`, + } + } + + /** + * @typedef {Object} BicProgressContext + * @property {string} contentIframe - A iframe element points to the image creation page. + * Note: This parameter may or may not present, depending on the function you are currently calling + * or the stage of the function execution. For now, it's presented only when genImageIframeSsr calls + * the onProgress at the first time. + * @property {Date} pollingStartTime - The start time of the polling request. + * Note: This parameter may or may not present, depending on the function you are currently calling + * or the stage of the function execution. For now, it's presented only in any 'polling' stage callbacks. + */ + + /** + * Polling the image creation request. + * @param {string} pollingUrl - The url to poll the image creation request. + * @param {function({BicProgressContext}):boolean} onProgress - A callback function that will be invoked intervally during the image generation. + * Return true to cancel creation. + * @returns {string} The result html string which contains the generated image links. + */ + async pollingImgRequest(pollingUrl, onProgress) { + let polling = true + let body + + if (typeof onProgress !== 'function') { + onProgress = () => false + } + + const pollingStartTime = new Date().getTime() + + while (polling) { + if (this.debug) { + console.debug(`polling the image request: ${pollingUrl}`) + } + + // eslint-disable-next-line no-await-in-loop + const response = await fetch(pollingUrl, this.fetchOptions) + const { status } = response + + if (status !== 200) { + throw new Error(`Bing Image Creator Error: response status = ${status}`) + } + + // eslint-disable-next-line no-await-in-loop + body = await response.text() + + if (body && body.indexOf('errorMessage') === -1) { + polling = false + } else { + const cancelRequest = onProgress({ pollingStartTime }) + if (cancelRequest) { + throw new Error('Bing Image Creator Error: cancelled') + } + + // eslint-disable-next-line no-await-in-loop + await this.constructor.sleep(1000) + } + } + + return body + } + + /** + * Get a list of the generated images. + * @param {string} prompt - The prompt for the image generation. It should be given by 'Sydney'. + * @param {string} messageId - The message ID refers to the message of 'Sydney'. + * @param {boolean} removeSizeLimit - Set it to true to remove the parameters according to the sizes from the reslut image links. + * @param {function({BicProgressContext}):boolean} onProgress - A callback function that will be invoked intervally during the image generation. + * Return true to cancel creation. + * @returns {string[]} An array containing the url strings of the generated images. + */ + async genImageList(prompt, messageId, removeSizeLimit, onProgress) { + const { pollingUrl } = await this.genImagePage(prompt, messageId) + const resultHtml = await this.pollingImgRequest(pollingUrl, onProgress) + if (this.debug) { + console.debug('The result of the request for image creation:') + console.debug(resultHtml) + console.debug() + } + + const regex = /(?<=src=")[^"]+(?=")/g + return Array.from(resultHtml.matchAll(regex), (match) => + (() => { + const l = this.constructor.decodeHtmlLite(match[0]) + return removeSizeLimit ? l.split('?w=')[0] : l + })(), + ) + } + + /** + * Create a html iframe element with the given src or srcdoc if isDoc is set to true. + * @param {string} src + * @param {boolean} isDoc + * @returns {string} The html string of the iframe created. + */ + createImageIframe(src, isDoc) { + return ( + '