Add poe bot

This commit is contained in:
wong2
2023-04-12 20:33:10 +08:00
parent 90127b0587
commit 257d2b0c57
21 changed files with 502 additions and 13 deletions

1
global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.gql'

View File

@@ -14,6 +14,7 @@
"@parcel/config-webextension": "^2.8.3",
"@parcel/optimizer-data-url": "2.8.3",
"@parcel/transformer-inline-string": "2.8.3",
"@rollup/plugin-graphql": "^2.0.3",
"@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.28",
"@types/react-copy-to-clipboard": "^5.0.4",

View File

@@ -1,16 +1,19 @@
import { BardBot } from './bard'
import { BingWebBot } from './bing'
import { ChatGPTBot } from './chatgpt'
import { ChatGPTApiBot } from './chatgpt-api'
import { PoeWebBot } from './poe'
export type BotId = 'chatgpt' | 'bing' | 'bard'
const botClasses: Record<BotId, typeof ChatGPTApiBot | typeof BingWebBot | typeof BardBot> = {
chatgpt: ChatGPTBot,
bing: BingWebBot,
bard: BardBot,
}
export type BotId = 'chatgpt' | 'bing' | 'bard' | 'claude'
export function createBotInstance(botId: BotId) {
return new botClasses[botId]()
switch (botId) {
case 'chatgpt':
return new ChatGPTBot()
case 'bing':
return new BingWebBot()
case 'bard':
return new BardBot()
case 'claude':
return new PoeWebBot()
}
}

64
src/app/bots/poe/api.ts Normal file
View File

@@ -0,0 +1,64 @@
import { ofetch } from 'ofetch'
import ChatViewQuery from './graphql/ChatViewQuery.graphql?raw'
import AddMessageBreakMutation from './graphql/AddMessageBreakMutation.graphql?raw'
import SendMessageMutation from './graphql/SendMessageMutation.graphql?raw'
import SubscriptionsMutation from './graphql/SubscriptionsMutation.graphql?raw'
import MessageAddedSubscription from './graphql/MessageAddedSubscription.graphql?raw'
import ViewerStateUpdatedSubscription from './graphql/ViewerStateUpdatedSubscription.graphql?raw'
import { ChatError, ErrorCode } from '~utils/errors'
export const GRAPHQL_QUERIES = {
AddMessageBreakMutation,
ChatViewQuery,
SendMessageMutation,
SubscriptionsMutation,
MessageAddedSubscription,
ViewerStateUpdatedSubscription,
}
export interface PoeSettings {
formkey: string
tchannelData: ChannelData
}
interface ChannelData {
minSeq: string
channel: string
channelHash: string
boxName: string
baseHost: string
targetUrl: string
enableWebsocket: boolean
}
export async function getPoeSettings() {
return ofetch<PoeSettings>('https://poe.com/api/settings')
}
export interface GqlHeaders {
formkey: string
tchannel: string
}
export async function gqlRequest(queryName: keyof typeof GRAPHQL_QUERIES, variables: any, poeSettings: PoeSettings) {
const query = GRAPHQL_QUERIES[queryName]
return ofetch('https://poe.com/api/gql_POST', {
method: 'POST',
body: {
query,
variables,
},
headers: {
'poe-formkey': poeSettings.formkey,
'poe-tchannel': poeSettings.tchannelData.channel,
},
})
}
export async function getChatId(bot: string, poeSettings: PoeSettings): Promise<number> {
const resp = await gqlRequest('ChatViewQuery', { bot }, poeSettings)
if (!resp.data) {
throw new ChatError('You need to login to Poe first', ErrorCode.POE_UNAUTHORIZED)
}
return resp.data.chatOfBot.chatId
}

View File

@@ -0,0 +1,17 @@
mutation AddMessageBreakMutation($chatId: BigInt!) {
messageBreakCreate(chatId: $chatId) {
message {
id
__typename
messageId
text
linkifiedText
authorNickname
state
vote
voteReason
creationTime
suggestedReplies
}
}
}

View File

@@ -0,0 +1,7 @@
mutation AutoSubscriptionMutation($subscriptions: [AutoSubscriptionQuery!]!) {
autoSubscribe(subscriptions: $subscriptions) {
viewer {
id
}
}
}

View File

@@ -0,0 +1,8 @@
query ChatViewQuery($bot: String!) {
chatOfBot(bot: $bot) {
id
chatId
defaultBotNickname
shouldShowDisclaimer
}
}

View File

@@ -0,0 +1,98 @@
subscription messageAdded($chatId: BigInt!) {
messageAdded(chatId: $chatId) {
id
messageId
creationTime
state
...ChatMessage_message
...chatHelpers_isBotMessage
}
}
fragment ChatMessageDownvotedButton_message on Message {
...MessageFeedbackReasonModal_message
...MessageFeedbackOtherModal_message
}
fragment ChatMessageDropdownMenu_message on Message {
id
messageId
vote
text
linkifiedText
...chatHelpers_isBotMessage
}
fragment ChatMessageFeedbackButtons_message on Message {
id
messageId
vote
voteReason
...ChatMessageDownvotedButton_message
}
fragment ChatMessageOverflowButton_message on Message {
text
...ChatMessageDropdownMenu_message
...chatHelpers_isBotMessage
}
fragment ChatMessageSuggestedReplies_SuggestedReplyButton_message on Message {
messageId
}
fragment ChatMessageSuggestedReplies_message on Message {
suggestedReplies
...ChatMessageSuggestedReplies_SuggestedReplyButton_message
}
fragment ChatMessage_message on Message {
id
messageId
text
author
linkifiedText
state
...ChatMessageSuggestedReplies_message
...ChatMessageFeedbackButtons_message
...ChatMessageOverflowButton_message
...chatHelpers_isHumanMessage
...chatHelpers_isBotMessage
...chatHelpers_isChatBreak
...chatHelpers_useTimeoutLevel
...MarkdownLinkInner_message
}
fragment MarkdownLinkInner_message on Message {
messageId
}
fragment MessageFeedbackOtherModal_message on Message {
id
messageId
}
fragment MessageFeedbackReasonModal_message on Message {
id
messageId
}
fragment chatHelpers_isBotMessage on Message {
...chatHelpers_isHumanMessage
...chatHelpers_isChatBreak
}
fragment chatHelpers_isChatBreak on Message {
author
}
fragment chatHelpers_isHumanMessage on Message {
author
}
fragment chatHelpers_useTimeoutLevel on Message {
id
state
text
messageId
}

View File

@@ -0,0 +1,40 @@
mutation chatHelpers_sendMessageMutation_Mutation(
$chatId: BigInt!
$bot: String!
$query: String!
$source: MessageSource
$withChatBreak: Boolean!
) {
messageEdgeCreate(chatId: $chatId, bot: $bot, query: $query, source: $source, withChatBreak: $withChatBreak) {
chatBreak {
cursor
node {
id
messageId
text
author
suggestedReplies
creationTime
state
}
id
}
message {
cursor
node {
id
messageId
text
author
suggestedReplies
creationTime
state
chat {
shouldShowDisclaimer
id
}
}
id
}
}
}

View File

@@ -0,0 +1,7 @@
mutation subscriptionsMutation($subscriptions: [AutoSubscriptionQuery!]!) {
autoSubscribe(subscriptions: $subscriptions) {
viewer {
id
}
}
}

View File

@@ -0,0 +1,43 @@
subscription viewerStateUpdated {
viewerStateUpdated {
id
...ChatPageBotSwitcher_viewer
}
}
fragment BotHeader_bot on Bot {
displayName
messageLimit {
dailyLimit
}
...BotImage_bot
}
fragment BotImage_bot on Bot {
image {
__typename
... on LocalBotImage {
localName
}
... on UrlBotImage {
url
}
}
displayName
}
fragment BotLink_bot on Bot {
displayName
}
fragment ChatPageBotSwitcher_viewer on Viewer {
availableBots {
id
messageLimit {
dailyLimit
}
...BotLink_bot
...BotHeader_bot
}
allowUserCreatedBots: booleanGate(gateName: "enable_user_created_bots")
}

149
src/app/bots/poe/index.ts Normal file
View File

@@ -0,0 +1,149 @@
import WebSocketAsPromised from 'websocket-as-promised'
import { requestHostPermission } from '~app/utils/permissions'
import { AbstractBot, SendMessageParams } from '../abstract-bot'
import { GRAPHQL_QUERIES, PoeSettings, getChatId, getPoeSettings, gqlRequest } from './api'
import { ChatError, ErrorCode } from '~utils/errors'
const BOT_ID = 'a2' // Claude-instant
interface ChatMessage {
id: string
author: string
text: string
state: 'complete' | 'incomplete'
messageId: number
}
interface WebsocketMessage {
message_type: 'subscriptionUpdate'
payload: {
subscription_name: 'messageAdded'
unique_id: string
data: {
messageAdded: ChatMessage
}
}
}
interface ConversationContext {
poeSettings: PoeSettings
chatId: number // user specific chat id for the bot
wsp: WebSocketAsPromised
}
export class PoeWebBot extends AbstractBot {
private conversationContext?: ConversationContext
constructor() {
super()
}
async doSendMessage(params: SendMessageParams) {
if (!(await requestHostPermission('https://*.poe.com/'))) {
throw new ChatError('Missing poe.com permission', ErrorCode.MISSING_POE_HOST_PERMISSION)
}
if (!this.conversationContext) {
const { poeSettings, chatId } = await this.getChatInfo()
const wsp = await this.connectWebsocket(poeSettings)
await this.subscribe(poeSettings)
this.conversationContext = { chatId, poeSettings, wsp }
}
const wsp = this.conversationContext.wsp
const onUnpackedMessageListener = (data: any) => {
console.debug('onUnpackedMessage', data)
const messages: WebsocketMessage[] = data.messages.map((s: string) => JSON.parse(s))
for (const m of messages) {
if (m.message_type === 'subscriptionUpdate' && m.payload.subscription_name === 'messageAdded') {
const chatMessage = m.payload.data.messageAdded
console.log(chatMessage)
params.onEvent({
type: 'UPDATE_ANSWER',
data: { text: chatMessage.text },
})
if (chatMessage.state === 'complete') {
params.onEvent({ type: 'DONE' })
wsp.onUnpackedMessage.removeAllListeners()
}
}
}
}
wsp.onUnpackedMessage.addListener(onUnpackedMessageListener)
await wsp.open()
await this.sendMessageRequest(params.prompt)
}
resetConversation() {
if (!this.conversationContext) {
return
}
const wsp = this.conversationContext.wsp
wsp.removeAllListeners()
wsp.close()
this.sendChatBreak()
this.conversationContext = undefined
}
private async getChatInfo() {
const poeSettings = await getPoeSettings()
const chatId = await getChatId(BOT_ID, poeSettings)
return { poeSettings, chatId }
}
private async sendMessageRequest(message: string) {
const { poeSettings, chatId } = this.conversationContext!
await gqlRequest(
'SendMessageMutation',
{
bot: BOT_ID,
chatId,
query: message,
source: null,
withChatBreak: false,
},
poeSettings,
)
}
private async sendChatBreak() {
const { chatId, poeSettings } = this.conversationContext!
await gqlRequest('AddMessageBreakMutation', { chatId }, poeSettings)
}
private async subscribe(poeSettings: PoeSettings) {
await gqlRequest(
'SubscriptionsMutation',
{
subscriptions: [
{
subscriptionName: 'messageAdded',
query: GRAPHQL_QUERIES.MessageAddedSubscription,
},
],
},
poeSettings,
)
}
private async getWebsocketUrl(poeSettings: PoeSettings) {
const domain = `tch${Math.floor(Math.random() * 1000000) + 1}`
const channel = poeSettings.tchannelData
return `wss://${domain}.tch.${channel.baseHost}/up/${channel.boxName}/updates?min_seq=${channel.minSeq}&channel=${channel.channel}&hash=${channel.channelHash}`
}
private async connectWebsocket(poeSettings: PoeSettings) {
const wsUrl = await this.getWebsocketUrl(poeSettings)
console.debug('ws url', wsUrl)
const wsp = new WebSocketAsPromised(wsUrl, {
packMessage: (data) => JSON.stringify(data),
unpackMessage: (data) => JSON.parse(data as string),
})
return wsp
}
}

View File

@@ -54,6 +54,13 @@ const ErrorAction: FC<{ error: ChatError }> = ({ error }) => {
</a>
)
}
if (error.code === ErrorCode.POE_UNAUTHORIZED) {
return (
<a href="https://poe.com" target="_blank" rel="noreferrer">
<Button color="primary" text="Login at poe.com" size="small" />
</a>
)
}
if (error.code === ErrorCode.CHATGPT_CLOUDFLARE || error.code === ErrorCode.CHATGPT_UNAUTHORIZED) {
return <ChatGPTAuthErrorAction />
}

View File

@@ -4,6 +4,7 @@ import settingIcon from '~/assets/icons/setting.svg'
import logo from '~/assets/logo.svg'
import NavLink from './NavLink'
import CommandBar from '../CommandBar'
import { CHATBOTS } from '~app/consts'
function IconButton(props: { icon: string }) {
return (
@@ -19,9 +20,9 @@ function Sidebar() {
<img src={logo} className="w-[79px] mb-[55px] mt-[66px] ml-5" />
<div className="flex flex-col gap-3">
<NavLink to="/" text="All-In-One" />
<NavLink to="/chat/$botId" params={{ botId: 'chatgpt' }} text="ChatGPT" />
<NavLink to="/chat/$botId" params={{ botId: 'bing' }} text="Bing" />
<NavLink to="/chat/$botId" params={{ botId: 'bard' }} text="Bard" />
{Object.entries(CHATBOTS).map(([botId, bot]) => (
<NavLink key={botId} to="/chat/$botId" params={{ botId }} text={bot.name} />
))}
</div>
<div className="mt-auto">
<hr className="border-[#ffffff4d]" />

View File

@@ -1,6 +1,7 @@
import chatgptLogo from '~/assets/chatgpt-logo.svg'
import bingLogo from '~/assets/bing-logo.svg'
import bardLogo from '~/assets/bard-logo.svg'
import claudeLogo from '~/assets/anthropic-logo.png'
import { BotId } from './bots'
export const CHATBOTS: Record<BotId, { name: string; avatar: any }> = {
@@ -16,6 +17,10 @@ export const CHATBOTS: Record<BotId, { name: string; avatar: any }> = {
name: 'Bard',
avatar: bardLogo,
},
claude: {
name: 'Claude',
avatar: claudeLogo,
},
}
export const CHATGPT_HOME_URL = 'https://chat.openai.com/chat'

View File

@@ -111,6 +111,7 @@ function SettingPage() {
{ name: 'ChatGPT', value: StartupPage.ChatGPT },
{ name: 'Bing', value: StartupPage.Bing },
{ name: 'Bard', value: StartupPage.Bard },
{ name: 'Claude', value: StartupPage.Claude },
]}
value={userConfig.startupPage}
onChange={(v) => updateConfigValue({ startupPage: v })}

View File

@@ -0,0 +1,5 @@
import Browser from 'webextension-polyfill'
export async function requestHostPermission(host: string) {
return Browser.permissions.request({ origins: [host] })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -7,6 +7,7 @@ export enum StartupPage {
ChatGPT = 'chatgpt',
Bing = 'bing',
Bard = 'bard',
Claude = 'claude',
}
export enum BingConversationStyle {

View File

@@ -7,6 +7,8 @@ export enum ErrorCode {
BING_FORBIDDEN = 'BING_FORBIDDEN',
API_KEY_NOT_SET = 'API_KEY_NOT_SET',
BARD_EMPTY_RESPONSE = 'BARD_EMPTY_RESPONSE',
MISSING_POE_HOST_PERMISSION = 'MISSING_POE_HOST_PERMISSION',
POE_UNAUTHORIZED = 'POE_UNAUTHORIZED',
}
export class ChatError extends Error {

View File

@@ -1764,6 +1764,14 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@rollup/plugin-graphql@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-graphql/-/plugin-graphql-2.0.3.tgz#35fea077e225e2982ce8483dd6c381e8cca03aea"
integrity sha512-IuuELo+0t29adRuLVg8izBFiUXFSFw8BmezespscynRfvfXSOV0S7g8RzQt75VzP6KHHVmNmlAgz+8qlkLur3w==
dependencies:
"@rollup/pluginutils" "^5.0.1"
graphql-tag "^2.12.6"
"@rollup/pluginutils@^4.1.2":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
@@ -1772,6 +1780,15 @@
estree-walker "^2.0.1"
picomatch "^2.2.2"
"@rollup/pluginutils@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.2.tgz#012b8f53c71e4f6f9cb317e311df1404f56e7a33"
integrity sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
picomatch "^2.3.1"
"@swc/helpers@^0.4.12":
version "0.4.14"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74"
@@ -1823,6 +1840,11 @@
dependencies:
"@types/ms" "*"
"@types/estree@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2"
integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==
"@types/hast@^2.0.0":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
@@ -3013,7 +3035,7 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
estree-walker@^2.0.1:
estree-walker@^2.0.1, estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
@@ -3315,6 +3337,13 @@ grapheme-splitter@^1.0.4:
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
graphql-tag@^2.12.6:
version "2.12.6"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==
dependencies:
tslib "^2.1.0"
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"