feat: add timeout support for browser sendMessage

pull/148/head
Travis Fischer 2022-12-16 01:05:33 -06:00
rodzic 8f5c9af919
commit d2cd6f8162
3 zmienionych plików z 199 dodań i 43 usunięć

Wyświetl plik

@ -24,7 +24,7 @@ const response = await api.sendMessage('Hello World!')
Note that this solution is not lightweight, but it does work a lot more consistently than the REST API-based versions. I'm currently using this solution to power 10 OpenAI accounts concurrently across 10 minimized Chrome windows for my [Twitter bot](https://github.com/transitive-bullshit/chatgpt-twitter-bot). 😂
If you get a "ChatGPT is at capacity" error when logging in, note that this can also happen on the official webapp as well. Their servers can get overloaded at times, and we're all trying our best to offer access to this amazing technology.
If you get a "ChatGPT is at capacity" error when logging in, note that this can also happen on the official webapp as well. Their servers get overloaded at times, and we're all trying our best to offer access to this amazing technology.
To use the updated version, **make sure you're using the latest version of this package and Node.js >= 18**. Then update your code following the examples below, paying special attention to the sections on [Authentication](#authentication) and [Restrictions](#restrictions).
@ -240,8 +240,6 @@ Pass `sessionToken`, `clearanceToken`, and `userAgent` to the `ChatGPTAPI` const
These restrictions are for the `getOpenAIAuth` + `ChatGPTAPI` solution, which uses the unofficial API. The browser-based solution, `ChatGPTAPIBrowser`, doesn't have many of these restrictions, though you'll still have to manually bypass CAPTCHAs by hand.
Note: currently `ChatGPTAPIBrowser` doesn't support continuing arbitrary conversations based on `conversationId`. You can only continue conversations in the current tab or start new conversations using the `resetThread()` function.
**Please read carefully**
- You must use `node >= 18` at the moment. I'm using `v19.2.0` in my testing.

Wyświetl plik

@ -38,8 +38,13 @@ export class ChatGPTAPIBrowser {
/** @defaultValue `false` **/
debug?: boolean
/** @defaultValue `false` **/
isGoogleLogin?: boolean
/** @defaultValue `true` **/
minimize?: boolean
/** @defaultValue `undefined` **/
captchaToken?: string
}) {
const {
@ -212,7 +217,6 @@ export class ChatGPTAPIBrowser {
} else {
const session: types.SessionResult = body
console.log('ACCESS TOKEN', session.accessToken)
if (session?.accessToken) {
this._accessToken = session.accessToken
}
@ -322,7 +326,7 @@ export class ChatGPTAPIBrowser {
messageId = uuidv4(),
action = 'next',
// TODO
// timeoutMs,
timeoutMs,
// onProgress,
onConversationResponse
} = opts
@ -360,7 +364,8 @@ export class ChatGPTAPIBrowser {
browserPostEventStream,
url,
this._accessToken,
body
body,
timeoutMs
)
// console.log('<<< EVALUATE', result)

Wyświetl plik

@ -1,3 +1,4 @@
import type * as PTimeoutTypes from 'p-timeout'
import type {
EventSourceParseCallback,
EventSourceParser
@ -72,13 +73,35 @@ export function isRelevantRequest(url: string): boolean {
export async function browserPostEventStream(
url: string,
accessToken: string,
body: types.ConversationJSONBody
body: types.ConversationJSONBody,
timeoutMs?: number
): Promise<types.ChatError | types.ChatResponse> {
const BOM = [239, 187, 191]
// Workaround for https://github.com/esbuild-kit/tsx/issues/113
globalThis.__name = () => undefined
class TimeoutError extends Error {
readonly name: 'TimeoutError'
constructor(message) {
super(message)
this.name = 'TimeoutError'
}
}
/**
An error to be thrown when the request is aborted by AbortController.
DOMException is thrown instead of this Error when DOMException is available.
*/
class AbortError extends Error {
constructor(message) {
super()
this.name = 'AbortError'
this.message = message
}
}
const BOM = [239, 187, 191]
let conversationId: string = body?.conversation_id
let messageId: string = body?.messages?.[0]?.id
let response = ''
@ -86,9 +109,15 @@ export async function browserPostEventStream(
try {
console.log('browserPostEventStream', url, accessToken, body)
let abortController: AbortController = null
if (timeoutMs) {
abortController = new AbortController()
}
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
signal: abortController?.signal,
headers: {
accept: 'text/event-stream',
'x-openai-assistant-app-id': '',
@ -97,7 +126,7 @@ export async function browserPostEventStream(
}
})
console.log('EVENT', res)
console.log('browserPostEventStream response', res)
if (!res.ok) {
return {
@ -112,48 +141,67 @@ export async function browserPostEventStream(
}
}
return await new Promise<types.ChatResponse>(async (resolve, reject) => {
function onMessage(data: string) {
if (data === '[DONE]') {
return resolve({
error: null,
response,
conversationId,
messageId
})
const responseP = new Promise<types.ChatResponse>(
async (resolve, reject) => {
function onMessage(data: string) {
if (data === '[DONE]') {
return resolve({
error: null,
response,
conversationId,
messageId
})
}
try {
const parsedData: types.ConversationResponseEvent = JSON.parse(data)
if (parsedData.conversation_id) {
conversationId = parsedData.conversation_id
}
if (parsedData.message?.id) {
messageId = parsedData.message.id
}
const partialResponse = parsedData.message?.content?.parts?.[0]
if (partialResponse) {
response = partialResponse
}
} catch (err) {
console.warn('fetchSSE onMessage unexpected error', err)
reject(err)
}
}
try {
const parsedData: types.ConversationResponseEvent = JSON.parse(data)
if (parsedData.conversation_id) {
conversationId = parsedData.conversation_id
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
})
if (parsedData.message?.id) {
messageId = parsedData.message.id
}
for await (const chunk of streamAsyncIterable(res.body)) {
const str = new TextDecoder().decode(chunk)
parser.feed(str)
}
}
)
const partialResponse = parsedData.message?.content?.parts?.[0]
if (partialResponse) {
response = partialResponse
}
} catch (err) {
console.warn('fetchSSE onMessage unexpected error', err)
reject(err)
if (timeoutMs) {
if (abortController) {
// This will be called when a timeout occurs in order for us to forcibly
// ensure that the underlying HTTP request is aborted.
;(responseP as any).cancel = () => {
abortController.abort()
}
}
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
return await pTimeout(responseP, {
milliseconds: timeoutMs,
message: 'ChatGPT timed out waiting for response'
})
for await (const chunk of streamAsyncIterable(res.body)) {
const str = new TextDecoder().decode(chunk)
parser.feed(str)
}
})
} else {
return await responseP
}
} catch (err) {
const errMessageL = err.toString().toLowerCase()
@ -367,4 +415,109 @@ export async function browserPostEventStream(
(charCode: number, index: number) => buffer.charCodeAt(index) === charCode
)
}
/**
TODO: Remove AbortError and just throw DOMException when targeting Node 18.
*/
function getDOMException(errorMessage) {
return globalThis.DOMException === undefined
? new AbortError(errorMessage)
: new DOMException(errorMessage)
}
/**
TODO: Remove below function and just 'reject(signal.reason)' when targeting Node 18.
*/
function getAbortedReason(signal) {
const reason =
signal.reason === undefined
? getDOMException('This operation was aborted.')
: signal.reason
return reason instanceof Error ? reason : getDOMException(reason)
}
// @see https://github.com/sindresorhus/p-timeout
function pTimeout<ValueType, ReturnType = ValueType>(
promise: PromiseLike<ValueType>,
options: PTimeoutTypes.Options<ReturnType>
): PTimeoutTypes.ClearablePromise<ValueType | ReturnType> {
const {
milliseconds,
fallback,
message,
customTimers = { setTimeout, clearTimeout }
} = options
let timer
const cancelablePromise = new Promise((resolve, reject) => {
if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) {
throw new TypeError(
`Expected \`milliseconds\` to be a positive number, got \`${milliseconds}\``
)
}
if (milliseconds === Number.POSITIVE_INFINITY) {
resolve(promise)
return
}
if (options.signal) {
const { signal } = options
if (signal.aborted) {
reject(getAbortedReason(signal))
}
signal.addEventListener('abort', () => {
reject(getAbortedReason(signal))
})
}
timer = customTimers.setTimeout.call(
undefined,
() => {
if (fallback) {
try {
resolve(fallback())
} catch (error) {
reject(error)
}
return
}
const errorMessage =
typeof message === 'string'
? message
: `Promise timed out after ${milliseconds} milliseconds`
const timeoutError =
message instanceof Error ? message : new TimeoutError(errorMessage)
if (typeof (promise as any).cancel === 'function') {
;(promise as any).cancel()
}
reject(timeoutError)
},
milliseconds
)
;(async () => {
try {
resolve(await promise)
} catch (error) {
reject(error)
} finally {
customTimers.clearTimeout.call(undefined, timer)
}
})()
})
;(cancelablePromise as any).clear = () => {
customTimers.clearTimeout.call(undefined, timer)
timer = undefined
}
return cancelablePromise as any
}
}