feat: WIP add support for native fetch; undici on node.js < 18, and refactor conversation support

pull/38/head
Travis Fischer 2022-12-06 16:13:11 -06:00
rodzic a1ac915a14
commit aaf5efc01b
12 zmienionych plików z 174 dodań i 197 usunięć

Wyświetl plik

@ -19,13 +19,14 @@
"build"
],
"engines": {
"node": ">=14"
"node": ">=16.8"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "del build",
"prebuild": "run-s clean",
"postbuild": "sed -i 's/ *\\?\\? *(await import(\"undici\")).fetch//' build/browser/index.js",
"predev": "run-s clean",
"pretest": "run-s build",
"docs": "typedoc",
@ -38,7 +39,6 @@
"dependencies": {
"eventsource-parser": "^0.0.5",
"expiry-map": "^2.0.0",
"node-fetch": "2",
"remark": "^14.0.2",
"strip-markdown": "^5.0.0",
"uuid": "^9.0.0"
@ -46,7 +46,6 @@
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.0.0",
"@types/node": "^18.11.9",
"@types/node-fetch": "2",
"@types/uuid": "^9.0.0",
"ava": "^5.1.0",
"del-cli": "^5.0.0",
@ -89,5 +88,8 @@
"ai",
"ml",
"bot"
]
],
"optionalDependencies": {
"undici": "^5.13.0"
}
}

Wyświetl plik

@ -3,7 +3,6 @@ lockfileVersion: 5.4
specifiers:
'@trivago/prettier-plugin-sort-imports': ^4.0.0
'@types/node': ^18.11.9
'@types/node-fetch': '2'
'@types/uuid': ^9.0.0
ava: ^5.1.0
del-cli: ^5.0.0
@ -12,7 +11,6 @@ specifiers:
expiry-map: ^2.0.0
husky: ^8.0.2
lint-staged: ^13.0.3
node-fetch: '2'
npm-run-all: ^4.1.5
ora: ^6.1.2
prettier: ^2.8.0
@ -23,20 +21,22 @@ specifiers:
typedoc: ^0.23.21
typedoc-plugin-markdown: ^3.13.6
typescript: ^4.9.3
undici: ^5.13.0
uuid: ^9.0.0
dependencies:
eventsource-parser: 0.0.5
expiry-map: 2.0.0
node-fetch: 2.6.7
remark: 14.0.2
strip-markdown: 5.0.0
uuid: 9.0.0
optionalDependencies:
undici: 5.13.0
devDependencies:
'@trivago/prettier-plugin-sort-imports': 4.0.0_prettier@2.8.0
'@types/node': 18.11.10
'@types/node-fetch': 2.6.2
'@types/uuid': 9.0.0
ava: 5.1.0
del-cli: 5.0.0
@ -434,13 +434,6 @@ packages:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
dev: false
/@types/node-fetch/2.6.2:
resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==}
dependencies:
'@types/node': 18.11.10
form-data: 3.0.1
dev: true
/@types/node/18.11.10:
resolution: {integrity: sha512-juG3RWMBOqcOuXC643OAdSA525V44cVgGV6dUDuiFtss+8Fk5x1hI93Rsld43VeJVIeqlP9I7Fn9/qaVqoEAuQ==}
dev: true
@ -568,10 +561,6 @@ packages:
engines: {node: '>=8'}
dev: true
/asynckit/0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/ava/5.1.0:
resolution: {integrity: sha512-e5VFrSQ0WBPyZJWRXVrO7RFOizFeNM0t2PORwrPvWtApgkORI6cvGnY3GX1G+lzpd0HjqNx5Jus22AhxVnUMNA==}
engines: {node: '>=14.19 <15 || >=16.15 <17 || >=18'}
@ -712,6 +701,14 @@ packages:
load-tsconfig: 0.2.3
dev: true
/busboy/1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
dependencies:
streamsearch: 1.1.0
dev: false
optional: true
/cac/6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@ -899,13 +896,6 @@ packages:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
dev: true
/combined-stream/1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
/commander/4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@ -1053,11 +1043,6 @@ packages:
slash: 4.0.0
dev: true
/delayed-stream/1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/dequal/2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -1491,15 +1476,6 @@ packages:
path-exists: 5.0.0
dev: true
/form-data/3.0.1:
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: true
/fs.realpath/1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
@ -2426,18 +2402,6 @@ packages:
picomatch: 2.3.1
dev: true
/mime-db/1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types/2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/mimic-fn/2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@ -2507,18 +2471,6 @@ packages:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
dev: true
/node-fetch/2.6.7:
resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-releases/2.0.6:
resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==}
dev: true
@ -3210,6 +3162,12 @@ packages:
escape-string-regexp: 2.0.0
dev: true
/streamsearch/1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
dev: false
optional: true
/string-argv/0.3.1:
resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==}
engines: {node: '>=0.6.19'}
@ -3382,10 +3340,6 @@ packages:
is-number: 7.0.0
dev: true
/tr46/0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false
/tr46/1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
dependencies:
@ -3522,6 +3476,15 @@ packages:
which-boxed-primitive: 1.0.2
dev: true
/undici/5.13.0:
resolution: {integrity: sha512-UDZKtwb2k7KRsK4SdXWG7ErXiL7yTGgLWvk2AXO1JMjgjh404nFo6tWSCM2xMpJwMPx3J8i/vfqEh1zOqvj82Q==}
engines: {node: '>=12.18'}
requiresBuild: true
dependencies:
busboy: 1.6.0
dev: false
optional: true
/unified/10.1.2:
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
dependencies:
@ -3627,10 +3590,6 @@ packages:
defaults: 1.0.4
dev: true
/webidl-conversions/3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false
/webidl-conversions/4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
dev: true
@ -3640,13 +3599,6 @@ packages:
engines: {node: '>=6'}
dev: true
/whatwg-url/5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: false
/whatwg-url/7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
dependencies:

Wyświetl plik

@ -63,7 +63,7 @@ if (!isCI) {
},
{
message:
'ChatGPT failed to refresh auth token. Error: session token has expired'
'ChatGPT failed to refresh auth token. Error: session token may have expired'
}
)
})

Wyświetl plik

@ -2,6 +2,7 @@ import ExpiryMap from 'expiry-map'
import { v4 as uuidv4 } from 'uuid'
import * as types from './types'
import { ChatGPTConversation } from './chatgpt-conversation'
import { fetch } from './fetch'
import { fetchSSE } from './fetch-sse'
import { markdownToText } from './utils'
@ -10,80 +11,10 @@ const KEY_ACCESS_TOKEN = 'accessToken'
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36'
/**
* A conversation wrapper around the ChatGPT API. This allows you to send
* multiple messages to ChatGPT and receive responses, without having to
* manually pass the conversation ID and parent message ID for each message.
*/
class Conversation {
api: ChatGPTAPI
conversationId: string = undefined
parentMessageId: string = undefined
/**
* Creates a new conversation wrapper around the ChatGPT API.
* @param api - The ChatGPT API instance to use.
*/
constructor(
api: ChatGPTAPI,
opts: { conversationId?: string; parentMessageId?: string } = {}
) {
this.api = api
this.conversationId = opts.conversationId
this.parentMessageId = opts.parentMessageId
}
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
* If this is the first message in the conversation, the conversation ID and
* parent message ID will be automatically set.
* This allows you to send multiple messages to ChatGPT and receive responses,
* without having to manually pass the conversation ID and parent message ID
* for each message.
* If you want to manually pass the conversation ID and parent message ID,
* use `api.sendMessage` instead.
*
* @param message - The plaintext message to send.
* @param opts.onProgress - Optional listener which will be called every time the partial response is updated
* @param opts.onConversationResponse - Optional listener which will be called every time a conversation response is received
* @returns The plaintext response from ChatGPT.
*/
async sendMessage(
message: string,
opts: {
onProgress?: (partialResponse: string) => void
onConversationResponse?: (
response: types.ConversationResponseEvent
) => void
} = {}
) {
const { onProgress, onConversationResponse } = opts
if (!this.conversationId) {
return this.api.sendMessage(message, {
onProgress,
onConversationResponse: (response) => {
this.conversationId = response.conversation_id
this.parentMessageId = response.message.id
onConversationResponse?.(response)
}
})
}
return this.api.sendMessage(message, {
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
onProgress,
onConversationResponse: (response) => {
this.conversationId = response.conversation_id
this.parentMessageId = response.message.id
onConversationResponse?.(response)
}
})
}
}
export class ChatGPTAPI {
public conversationId: string = undefined
public parentMessageId: string = undefined
protected _sessionToken: string
protected _markdown: boolean
protected _apiBaseUrl: string
@ -152,10 +83,13 @@ export class ChatGPTAPI {
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
*
* @param message - The plaintext message to send.
* @param opts.conversationId - Optional ID of the previous message in a conversation
* @param opts.onProgress - Optional listener which will be called every time the partial response is updated
* @param opts.onConversationResponse - Optional listener which will be called every time the partial response is updated with the full conversation response
* @param message - The prompt message to send
* @param opts.conversationId - Optional ID of a conversation to continue
* @param opts.parentMessageId - Optional ID of the previous message in the conversation
* @param opts.onProgress - Optional function which will be called every time the partial response is updated
* @param opts.onConversationResponse - Optional function which will be called every time the partial response is updated with the full conversation response
* @param opts.abortSignal - Optional function used to abort the underlying `fetch` call using an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
* @returns The response from ChatGPT
*/
async sendMessage(
message: string,
@ -166,13 +100,15 @@ export class ChatGPTAPI {
onConversationResponse?: (
response: types.ConversationResponseEvent
) => void
abortSignal?: AbortSignal
} = {}
): Promise<string> {
const {
conversationId,
parentMessageId = uuidv4(),
onProgress,
onConversationResponse
onConversationResponse,
abortSignal
} = opts
const accessToken = await this.refreshAccessToken()
@ -214,6 +150,7 @@ export class ChatGPTAPI {
'user-agent': this._userAgent
},
body: JSON.stringify(body),
signal: abortSignal,
onMessage: (data: string) => {
if (data === '[DONE]') {
return resolve(response)
@ -275,7 +212,7 @@ export class ChatGPTAPI {
const error = res?.error
if (error) {
if (error === 'RefreshAccessTokenError') {
throw new Error('session token has expired')
throw new Error('session token may have expired')
} else {
throw new Error(error)
}
@ -289,15 +226,16 @@ export class ChatGPTAPI {
}
/**
* Get a new Conversation instance, which can be used to send multiple messages as part of a single conversation.
* Gets a new ChatGPTConversation instance, which can be used to send multiple
* messages as part of a single conversation.
*
* @param opts.conversationId - Optional Data of the previous message in a conversation
* @param opts.parentMessageId - Optional Data of the previous message in a conversation
* @returns a new Conversation instance
* @param opts.conversationId - Optional ID of the previous message in a conversation
* @param opts.parentMessageId - Optional ID of the previous message in a conversation
* @returns The new conversation instance
*/
getConversation(
opts: { conversationId?: string; parentMessageId?: string } = {}
) {
return new Conversation(this, opts)
return new ChatGPTConversation(this, opts)
}
}

Wyświetl plik

@ -0,0 +1,71 @@
import * as types from './types'
import { type ChatGPTAPI } from './chatgpt-api'
/**
* A conversation wrapper around the ChatGPTAPI. This allows you to send
* multiple messages to ChatGPT and receive responses, without having to
* manually pass the conversation ID and parent message ID for each message.
*/
export class ChatGPTConversation {
api: ChatGPTAPI
conversationId: string = undefined
parentMessageId: string = undefined
/**
* Creates a new conversation wrapper around the ChatGPT API.
*
* @param api - The ChatGPT API instance to use
* @param opts.conversationId - Optional ID of a conversation to continue
* @param opts.parentMessageId - Optional ID of the previous message in the conversation
*/
constructor(
api: ChatGPTAPI,
opts: { conversationId?: string; parentMessageId?: string } = {}
) {
this.api = api
this.conversationId = opts.conversationId
this.parentMessageId = opts.parentMessageId
}
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
*
* If this is the first message in the conversation, the conversation ID and
* parent message ID will be automatically set.
*
* This allows you to send multiple messages to ChatGPT and receive responses,
* without having to manually pass the conversation ID and parent message ID
* for each message.
*
* @param message - The prompt message to send
* @param opts.onProgress - Optional listener which will be called every time the partial response is updated
* @param opts.onConversationResponse - Optional listener which will be called every time a conversation response is received
* @returns The response from ChatGPT
*/
async sendMessage(
message: string,
opts: {
onProgress?: (partialResponse: string) => void
onConversationResponse?: (
response: types.ConversationResponseEvent
) => void
} = {}
): Promise<string> {
const { onProgress, onConversationResponse } = opts
return this.api.sendMessage(message, {
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
onProgress,
onConversationResponse: (response) => {
this.conversationId = response.conversation_id
this.parentMessageId = response.message.id
if (onConversationResponse) {
return onConversationResponse(response)
}
}
})
}
}

Wyświetl plik

@ -6,10 +6,10 @@ import { ChatGPTAPI } from '.'
dotenv.config()
/**
* Example CLI for testing functionality.
* Demo CLI for testing conversation support.
*
* ```
* npx tsx src/demo.ts
* npx tsx src/demo-conversation.ts
* ```
*/
async function main() {

Wyświetl plik

@ -6,7 +6,7 @@ import { ChatGPTAPI } from '.'
dotenv.config()
/**
* Example CLI for testing functionality.
* Demo CLI for testing basic functionality.
*
* ```
* npx tsx src/demo.ts

Wyświetl plik

@ -1,8 +1,7 @@
import { createParser } from 'eventsource-parser'
import { fetch } from './fetch'
// import { streamAsyncIterable } from './stream-async-iterable'
import { streamAsyncIterable } from './stream-async-iterable'
export async function fetchSSE(
url: string,
@ -16,16 +15,8 @@ export async function fetchSSE(
}
})
resp.body.on('readable', () => {
let chunk: string | Buffer
while (null !== (chunk = resp.body.read())) {
parser.feed(chunk.toString())
}
})
// TODO: add support for web-compatible `fetch`
// for await (const chunk of streamAsyncIterable(resp.body)) {
// const str = new TextDecoder().decode(chunk)
// parser.feed(str)
// }
for await (const chunk of streamAsyncIterable(resp.body)) {
const str = new TextDecoder().decode(chunk)
parser.feed(str)
}
}

Wyświetl plik

@ -1,3 +1,11 @@
import fetch from 'node-fetch'
/// <reference lib="dom" />
// Use `undici` for node.js 16 and 17
// Use `fetch` for node.js >= 18
// Use `fetch` for browsers
// Use `fetch` for all other environments
const fetch =
globalThis.fetch ??
((await import('undici')).fetch as unknown as typeof globalThis.fetch)
export { fetch }

Wyświetl plik

@ -1,3 +1,4 @@
export * from './chatgpt-api'
export * from './chatgpt-conversation'
export * from './types'
export * from './utils'

Wyświetl plik

@ -1,6 +1,4 @@
import { type ReadableStream } from 'stream/web'
export async function* streamAsyncIterable(stream: ReadableStream) {
export async function* streamAsyncIterable<T>(stream: ReadableStream<T>) {
const reader = stream.getReader()
try {
while (true) {

Wyświetl plik

@ -1,14 +1,30 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
outDir: 'build',
target: 'node14',
platform: 'node',
format: ['esm'],
splitting: false,
sourcemap: true,
minify: true,
shims: false,
dts: true
})
export default defineConfig([
{
entry: ['src/index.ts'],
outDir: 'build',
target: 'node16',
platform: 'node',
format: ['esm'],
splitting: false,
sourcemap: true,
minify: false,
shims: true,
dts: true,
external: ['undici']
},
{
entry: ['src/index.ts'],
outDir: 'build/browser',
target: 'chrome89',
platform: 'browser',
format: ['esm'],
splitting: false,
sourcemap: true,
minify: false,
shims: true,
dts: true,
external: ['undici']
}
])