chatgpt-api/src/chatgpt-api.ts

210 wiersze
5.4 KiB
TypeScript
Czysty Zwykły widok Historia

2022-12-02 23:43:59 +00:00
import delay from 'delay'
import html2md from 'html-to-md'
import { type ChromiumBrowserContext, type Page, chromium } from 'playwright'
export class ChatGPTAPI {
protected _userDataDir: string
protected _headless: boolean
protected _markdown: boolean
protected _chatUrl: string
protected _browser: ChromiumBrowserContext
protected _page: Page
/**
* @param opts.userDataDir  Path to a directory for storing persistent chromium session data
* @param opts.chatUrl  OpenAI chat URL
* @param opts.headless - Whether or not to use headless mode
* @param opts.markdown  Whether or not to parse chat messages as markdown
*/
constructor(
opts: {
/** @defaultValue `'/tmp/chatgpt'` **/
userDataDir?: string
/** @defaultValue `'https://chat.openai.com/'` **/
chatUrl?: string
/** @defaultValue `false` **/
headless?: boolean
/** @defaultValue `true` **/
markdown?: boolean
} = {}
) {
const {
userDataDir = '/tmp/chatgpt',
chatUrl = 'https://chat.openai.com/',
headless = false,
markdown = true
} = opts
this._userDataDir = userDataDir
this._headless = !!headless
this._chatUrl = chatUrl
this._markdown = !!markdown
}
2022-12-03 00:04:53 +00:00
async init(opts: { auth?: 'blocking' | 'eager' } = {}) {
const { auth = 'eager' } = opts
if (this._browser) {
await this.close()
}
2022-12-02 23:43:59 +00:00
this._browser = await chromium.launchPersistentContext(this._userDataDir, {
headless: this._headless
})
this._page = await this._browser.newPage()
await this._page.goto(this._chatUrl)
// dismiss welcome modal
do {
const modalSelector = '[data-headlessui-state="open"]'
2022-12-03 08:46:57 +00:00
if (!(await this._page.$(modalSelector))) {
2022-12-02 23:43:59 +00:00
break
}
2022-12-03 08:46:57 +00:00
try {
await this._page.click(`${modalSelector} button:last-child`, {
timeout: 1000
})
} catch (err) {
// "next" button not found in welcome modal
2022-12-02 23:43:59 +00:00
break
}
} while (true)
2022-12-03 00:04:53 +00:00
if (auth === 'blocking') {
do {
const isSignedIn = await this.getIsSignedIn()
if (isSignedIn) {
break
}
2022-12-03 08:46:57 +00:00
console.log(
'Please sign in to ChatGPT using the Chromium browser window and dismiss the welcome modal...'
)
2022-12-03 00:04:53 +00:00
await delay(1000)
} while (true)
}
2022-12-02 23:43:59 +00:00
return this._page
}
async getIsSignedIn() {
2022-12-03 08:46:57 +00:00
try {
const inputBox = await this._getInputBox()
return !!inputBox
} catch (err) {
// can happen when navigating during login
return false
}
2022-12-02 23:43:59 +00:00
}
async getLastMessage(): Promise<string | null> {
const messages = await this.getMessages()
if (messages) {
return messages[messages.length - 1]
} else {
return null
}
}
async getPrompts(): Promise<string[]> {
// Get all prompts
const messages = await this._page.$$(
'[class*="ConversationItem__Message"]:has([class*="ConversationItem__ActionButtons"]):has([class*="ConversationItem__Role"] [class*="Avatar__Wrapper"])'
)
// prompts are always plaintext
return Promise.all(messages.map((a) => a.innerText()))
}
async getMessages(): Promise<string[]> {
// Get all complete messages
// (in-progress messages that are being streamed back don't contain action buttons)
const messages = await this._page.$$(
'[class*="ConversationItem__Message"]:has([class*="ConversationItem__ActionButtons"]):not(:has([class*="ConversationItem__Role"] [class*="Avatar__Wrapper"]))'
)
if (this._markdown) {
const htmlMessages = await Promise.all(messages.map((a) => a.innerHTML()))
const markdownMessages = htmlMessages.map((messageHtml) => {
// parse markdown from message HTML
messageHtml = messageHtml.replace('Copy code</button>', '</button>')
return html2md(messageHtml, {
ignoreTags: [
'button',
'svg',
'style',
'form',
'noscript',
'script',
'meta',
'head'
],
skipTags: ['button', 'svg']
})
})
return markdownMessages
} else {
// plaintext
const plaintextMessages = await Promise.all(
messages.map((a) => a.innerText())
)
return plaintextMessages
}
}
async sendMessage(message: string): Promise<string> {
const inputBox = await this._getInputBox()
if (!inputBox) throw new Error('not signed in')
const lastMessage = await this.getLastMessage()
2022-12-03 08:46:57 +00:00
await inputBox.click({ force: true })
await inputBox.fill(message, { force: true })
2022-12-02 23:43:59 +00:00
await inputBox.press('Enter')
do {
await delay(1000)
// TODO: this logic needs some work because we can have repeat messages...
const newLastMessage = await this.getLastMessage()
if (
newLastMessage &&
lastMessage?.toLowerCase() !== newLastMessage?.toLowerCase()
) {
return newLastMessage
}
} while (true)
}
2022-12-05 05:14:23 +00:00
async resetThread() {
const resetButton = await this._page.$('nav > a:nth-child(1)')
if (!resetButton) throw new Error('not signed in')
await resetButton.click()
}
2022-12-02 23:43:59 +00:00
async close() {
await this._browser.close()
this._page = null
this._browser = null
2022-12-02 23:43:59 +00:00
}
protected async _getInputBox(): Promise<any> {
return this._page.$(
'div[class*="PromptTextarea__TextareaWrapper"] textarea'
)
}
}