This commit is contained in:
wong2
2023-02-21 17:10:17 +08:00
commit 12152c2db4
45 changed files with 208260 additions and 0 deletions

23
.eslintrc.json Normal file
View File

@@ -0,0 +1,23 @@
{
"parser": "@typescript-eslint/parser",
"env": {
"browser": true
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"overrides": [],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"react/react-in-jsx-scope": "off"
},
"ignorePatterns": ["build/**"]
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"printWidth": 120,
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}

12
manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"manifest_version": 3,
"name": "ChatHub",
"version": "0.0.1",
"background": {
"service_worker": "src/background/index.ts",
"type": "module"
},
"action": {},
"host_permissions": ["https://*.bing.com/", "https://*.openai.com/"],
"permissions": ["storage"]
}

68
package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "chatbox-extension",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.12",
"@parcel/config-webextension": "^2.8.3",
"@parcel/optimizer-data-url": "2.8.3",
"@parcel/transformer-inline-string": "2.8.3",
"@types/lodash-es": "^4.17.6",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-scroll-to-bottom": "^4.2.0",
"@types/uuid": "^9.0.0",
"@types/webextension-polyfill": "^0.10.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"parcel": "^2.8.3",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"process": "^0.11.10",
"tailwindcss": "^3.2.7",
"typescript": "^4.9.3",
"vite": "^4.1.0",
"vite-tsconfig-paths": "^4.0.5"
},
"dependencies": {
"@chakra-ui/icons": "^2.0.17",
"@chakra-ui/react": "^2.5.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@tanstack/react-router": "^0.0.1-beta.83",
"eventsource-parser": "^0.1.0",
"expiry-map": "^2.0.0",
"framer-motion": "^9.0.4",
"github-markdown-css": "^5.2.0",
"immer": "^9.0.19",
"jotai": "^2.0.2",
"jotai-immer": "^0.2.0",
"lodash-es": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^2.0.0",
"react-markdown": "^8.0.5",
"react-scroll-to-bottom": "^4.2.0",
"remark-gfm": "^3.0.1",
"remark-supersub": "^1.0.0",
"use-immer": "^0.8.1",
"uuid": "^9.0.0",
"webextension-polyfill": "^0.10.0",
"websocket-as-promised": "^2.0.1",
"wretch": "^2.4.1",
"zustand": "^4.3.3"
}
}

8
postcss.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': 'postcss-nesting',
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,32 @@
import { ErrorCode } from '~utils/errors'
export type Event =
| {
type: 'UPDATE_ANSWER'
data: {
text: string
}
}
| {
type: 'DONE'
}
| {
type: 'ERROR'
data: {
code: ErrorCode
message: string
}
}
export interface SendMessageParams {
prompt: string
onEvent: (event: Event) => void
signal?: AbortSignal
}
export abstract class AbstractBot {
abstract name: string
abstract logo: string
abstract sendMessage(params: SendMessageParams): Promise<void>
abstract resetConversation(): Promise<void>
}

17
src/app/bots/bing/api.ts Normal file
View File

@@ -0,0 +1,17 @@
import wretch from 'wretch'
import { uuid } from '~utils'
import { ConversationResponse } from './types'
export async function createConversation(): Promise<ConversationResponse> {
const resp: ConversationResponse = await wretch('https://www.bing.com/turing/conversation/create')
.headers({
'x-ms-client-request-id': uuid(),
'x-ms-useragent': 'azsdk-js-api-client-factory/1.0.0-beta.1 core-rest-pipeline/1.10.0 OS/Win32',
})
.get()
.json()
if (resp.result.value !== 'Success') {
throw new Error(`Failed to create conversation: ${resp.result.value} ${resp.result.message}`)
}
return resp
}

117557
src/app/bots/bing/code.js Normal file

File diff suppressed because one or more lines are too long

83611
src/app/bots/bing/code2.js Normal file

File diff suppressed because one or more lines are too long

100
src/app/bots/bing/index.ts Normal file
View File

@@ -0,0 +1,100 @@
import WebSocketAsPromised from 'websocket-as-promised'
import logo from '~/assets/bing-logo.png'
import { ErrorCode } from '~utils/errors'
import { AbstractBot, SendMessageParams } from '../abstract-bot'
import { createConversation } from './api'
import { ChatResponseMessage, ConversationInfo, InvocationEventType } from './types'
import { convertMessageToMarkdown, websocketUtils } from './utils'
export class BingWebBot extends AbstractBot {
name = 'Bing'
logo = logo
private conversationContext?: ConversationInfo
private buildChatRequest(conversation: ConversationInfo, message: string) {
return {
arguments: [
{
source: 'cib',
optionsSets: [
'nlu_direct_response_filter',
'deepleo',
'disable_emoji_spoken_text',
'responsible_ai_policy_235',
'enablemm',
],
allowedMessageTypes: ['Chat', 'InternalSearchQuery'],
isStartOfSession: conversation.invocationId === 0,
message: {
author: 'user',
inputMethod: 'Keyboard',
text: message,
messageType: 'Chat',
},
conversationId: conversation.conversationId,
conversationSignature: conversation.conversationSignature,
participant: { id: conversation.clientId },
},
],
invocationId: conversation.invocationId.toString(),
target: 'chat',
type: InvocationEventType.StreamInvocation,
}
}
async sendMessage(params: SendMessageParams) {
if (!this.conversationContext) {
const conversation = await createConversation()
this.conversationContext = {
conversationId: conversation.conversationId,
conversationSignature: conversation.conversationSignature,
clientId: conversation.clientId,
invocationId: 0,
}
}
const conversation = this.conversationContext!
const wsp = new WebSocketAsPromised('wss://sydney.bing.com/sydney/ChatHub', {
packMessage: websocketUtils.packMessage,
unpackMessage: websocketUtils.unpackMessage,
})
wsp.onUnpackedMessage.addListener((events) => {
for (const event of events) {
if (JSON.stringify(event) === '{}') {
wsp.sendPacked({ type: InvocationEventType.Ping })
wsp.sendPacked(this.buildChatRequest(conversation, params.prompt))
conversation.invocationId += 1
} else if (event.type === 3) {
params.onEvent({ type: 'DONE' })
wsp.removeAllListeners()
wsp.close()
} else if (event.type === 1) {
const text = convertMessageToMarkdown(event.arguments[0].messages[0])
params.onEvent({ type: 'UPDATE_ANSWER', data: { text } })
} else if (event.type === 2) {
const messages = event.item.messages as ChatResponseMessage[]
const limited = messages.some((message) => message.contentOrigin === 'TurnLimiter')
if (limited) {
params.onEvent({
type: 'ERROR',
data: {
code: ErrorCode.CONVERSATION_LIMIT,
message: 'Sorry, you have reached chat turns limit in this conversation.',
},
})
}
}
}
})
await wsp.open()
wsp.sendPacked({ protocol: 'json', version: 1 })
}
async resetConversation(): Promise<void> {
this.conversationContext = undefined
}
}

103
src/app/bots/bing/types.ts Normal file
View File

@@ -0,0 +1,103 @@
export interface ConversationResponse {
conversationId: string
clientId: string
conversationSignature: string
result: {
value: string
message: null
}
}
export enum InvocationEventType {
Invocation = 1,
StreamItem = 2,
Completion = 3,
StreamInvocation = 4,
CancelInvocation = 5,
Ping = 6,
Close = 7,
}
// https://github.com/bytemate/bingchat-api/blob/main/src/lib.ts
export interface ConversationInfo {
conversationId: string
clientId: string
conversationSignature: string
invocationId: number
}
export interface BingChatResponse {
conversationSignature: string
conversationId: string
clientId: string
invocationId: number
conversationExpiryTime: Date
response: string
details: ChatResponseMessage
}
export interface ChatResponseMessage {
text: string
author: string
createdAt: Date
timestamp: Date
messageId: string
messageType?: string
requestId: string
offense: string
adaptiveCards: AdaptiveCard[]
sourceAttributions: SourceAttribution[]
feedback: Feedback
contentOrigin: string
privacy: null
suggestedResponses: SuggestedResponse[]
}
export interface AdaptiveCard {
type: string
version: string
body: Body[]
}
export interface Body {
type: string
text: string
wrap: boolean
size?: string
}
export interface Feedback {
tag: null
updatedOn: null
type: string
}
export interface SourceAttribution {
providerDisplayName: string
seeMoreUrl: string
searchQuery: string
}
export interface SuggestedResponse {
text: string
author: string
createdAt: Date
timestamp: Date
messageId: string
messageType: string
offense: string
feedback: Feedback
contentOrigin: string
privacy: null
}
export async function generateMarkdown(response: BingChatResponse) {
// change `[^Number^]` to markdown link
const regex = /\[\^(\d+)\^\]/g
const markdown = response.details.text.replace(regex, (match, p1) => {
const sourceAttribution = response.details.sourceAttributions[Number(p1) - 1]
return `[${sourceAttribution.providerDisplayName}](${sourceAttribution.seeMoreUrl})`
})
return markdown
}

View File

@@ -0,0 +1,31 @@
import { ChatResponseMessage } from './types'
export function convertMessageToMarkdown(message: ChatResponseMessage): string {
if (message.messageType === 'InternalSearchQuery') {
return message.text
}
let adaptiveCardText = ''
for (const card of message.adaptiveCards) {
for (const block of card.body) {
if (block.type === 'TextBlock') {
adaptiveCardText += '\n' + block.text
}
}
}
return adaptiveCardText
}
const RecordSeparator = String.fromCharCode(30)
export const websocketUtils = {
packMessage(data: any) {
return `${JSON.stringify(data)}${RecordSeparator}`
},
unpackMessage(data: string | ArrayBuffer | Blob) {
return data
.toString()
.split(RecordSeparator)
.filter(Boolean)
.map((s) => JSON.parse(s))
},
}

View File

@@ -0,0 +1,22 @@
export async function getChatGPTAccessToken(): Promise<string> {
const resp = await fetch('https://chat.openai.com/api/auth/session')
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const data = await resp.json().catch(() => ({}))
if (!data.accessToken) {
throw new Error('UNAUTHORIZED')
}
return data.accessToken
}
export async function requestBackendAPIWithToken(token: string, method: string, path: string, data?: unknown) {
return fetch(`https://chat.openai.com/backend-api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: data === undefined ? undefined : JSON.stringify(data),
})
}

View File

@@ -0,0 +1,100 @@
import { v4 as uuidv4 } from 'uuid'
import logo from '~/assets/chatgpt-logo.svg'
import { fetchSSE } from '~/utils/fetch-sse'
import { AbstractBot, SendMessageParams } from '../abstract-bot'
import { getChatGPTAccessToken, requestBackendAPIWithToken } from './api'
interface ConversationContext {
conversationId: string
lastMessageId: string
}
export class ChatGPTWebBot implements AbstractBot {
name = 'ChatGPT'
logo = logo
private accessToken?: string
private conversationContext?: ConversationContext
private modelName?: string
private async fetchModels(): Promise<{ slug: string; title: string; description: string; max_tokens: number }[]> {
const resp = await requestBackendAPIWithToken(this.accessToken!, 'GET', '/models').then((r) => r.json())
return resp.models
}
private async getModelName(): Promise<string> {
if (this.modelName) {
return this.modelName
}
try {
const models = await this.fetchModels()
this.modelName = models[0].slug
return this.modelName
} catch (err) {
console.error(err)
return 'text-davinci-002-render'
}
}
async sendMessage(params: SendMessageParams) {
if (!this.accessToken) {
this.accessToken = await getChatGPTAccessToken()
}
const modelName = await this.getModelName()
console.debug('Using model:', modelName)
await fetchSSE('https://chat.openai.com/backend-api/conversation', {
method: 'POST',
signal: params.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.accessToken}`,
},
body: JSON.stringify({
action: 'next',
messages: [
{
id: uuidv4(),
role: 'user',
content: {
content_type: 'text',
parts: [params.prompt],
},
},
],
model: modelName,
conversation_id: this.conversationContext?.conversationId || undefined,
parent_message_id: this.conversationContext?.lastMessageId || uuidv4(),
}),
onMessage: (message: string) => {
console.debug('sse message', message)
if (message === '[DONE]') {
params.onEvent({ type: 'DONE' })
return
}
let data
try {
data = JSON.parse(message)
} catch (err) {
console.error(err)
return
}
const text = data.message?.content?.parts?.[0]
if (text) {
this.conversationContext = {
conversationId: data.conversation_id,
lastMessageId: data.message.id,
}
params.onEvent({
type: 'UPDATE_ANSWER',
data: { text },
})
}
},
})
}
async resetConversation(): Promise<void> {
this.conversationContext = undefined
}
}

10
src/app/bots/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { AbstractBot } from './abstract-bot'
import { BingWebBot } from './bing'
import { ChatGPTWebBot } from './chatgpt-webapp'
export type BotId = 'chatgpt' | 'bing'
export const botClasses: Record<BotId, typeof ChatGPTWebBot | typeof BingWebBot> = {
chatgpt: ChatGPTWebBot,
bing: BingWebBot,
}

View File

@@ -0,0 +1,43 @@
import { Avatar } from '@chakra-ui/react'
import remarkGfm from 'remark-gfm'
import supersub from 'remark-supersub'
import ReactMarkdown from 'react-markdown'
import { FC, useMemo } from 'react'
import 'github-markdown-css'
import { ChatMessageModel } from '~/types'
import { CHATBOTS } from '../../consts'
import classes from './card.module.css'
interface Props {
message: ChatMessageModel
}
const ChatMessageCard: FC<Props> = ({ message }) => {
const user = useMemo(() => {
if (message.author === 'chatgpt') {
return CHATBOTS.chatgpt
}
if (message.author === 'bing') {
return CHATBOTS.bing
}
}, [message])
return (
<div className="flex flex-row gap-3">
<div>
<Avatar src={user?.avatar} size="sm" />
</div>
<div className="flex flex-col">
<span className="text-sm opacity-50">{user?.name || 'You'}</span>
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm]}
className={`markdown-body ${classes.markdown}`}
linkTarget="_blank"
>
{message.text}
</ReactMarkdown>
</div>
</div>
)
}
export default ChatMessageCard

View File

@@ -0,0 +1,24 @@
import { FC } from 'react'
import ScrollToBottom from 'react-scroll-to-bottom'
import { BotId } from '~app/bots'
import { ChatMessageModel } from '~types'
import ChatMessageCard from './ChatMessageCard'
interface Props {
botId: BotId
messages: ChatMessageModel[]
}
const MessageList: FC<Props> = (props) => {
return (
<ScrollToBottom className="overflow-scroll h-full">
<div className="mx-auto flex flex-col gap-3 px-10 h-full">
{props.messages.map((message) => {
return <ChatMessageCard key={message.id} message={message} />
})}
</div>
</ScrollToBottom>
)
}
export default MessageList

View File

@@ -0,0 +1,40 @@
import { Container, Input } from '@chakra-ui/react'
import { FC, useCallback } from 'react'
import { ChatMessageModel } from '~types'
import { BotId } from '../../bots'
import MessageList from './ChatMessageList'
interface Props {
botId: BotId
messages: ChatMessageModel[]
onUserSendMessage: (input: string, botId: BotId) => void
}
const ConversationPanel: FC<Props> = (props) => {
const onSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const formData = new FormData(form)
const { input } = Object.fromEntries(formData.entries())
form.reset()
if (input) {
props.onUserSendMessage(input as string, props.botId)
}
},
[props],
)
return (
<div className="py-5 flex flex-col overflow-hidden">
<div className="text-center font-bold">{props.botId}</div>
<MessageList botId={props.botId} messages={props.messages} />
<Container maxW="md" className="my-0">
<form onSubmit={onSubmit}>
<Input name="input" autoComplete="off" />
</form>
</Container>
</div>
)
}
export default ConversationPanel

View File

@@ -0,0 +1,10 @@
.markdown {
> p {
margin-bottom: 5px;
}
> ul,
ol {
list-style: disc;
padding-left: 1em;
}
}

View File

@@ -0,0 +1,29 @@
import { Link } from '@tanstack/react-router'
function Sidebar() {
return (
<aside className="bg-gray-900 p-2 flex flex-col text-white">
<span>ChatHub</span>
<div className="mt-10 flex flex-col gap-3">
<span>
<Link to="/">All-in-One</Link>
</span>
<span>
<Link to="/chat/$botId" params={{ botId: 'chatgpt' }}>
ChatGPT
</Link>
</span>
<span>
<Link to="/chat/$botId" params={{ botId: 'bing' }}>
Bing
</Link>
</span>
</div>
<span className="mt-auto"></span>
<span>Feedback</span>
<span>About</span>
</aside>
)
}
export default Sidebar

13
src/app/consts.ts Normal file
View File

@@ -0,0 +1,13 @@
import chatgptLogo from '~/assets/chatgpt-logo.svg'
import bingLogo from '~/assets/bing-logo.png'
export const CHATBOTS = {
chatgpt: {
name: 'ChatGPT',
avatar: chatgptLogo,
},
bing: {
name: 'Bing',
avatar: bingLogo,
},
}

35
src/app/hooks/use-chat.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useCallback, useMemo } from 'react'
import { useImmer } from 'use-immer'
import { ChatMessageModel } from '~types'
import { uuid } from '~utils'
import { botClasses, BotId } from '../bots'
export function useChat(botId: BotId) {
const bot = useMemo(() => new botClasses[botId](), [botId])
const [messages, setMessages] = useImmer<ChatMessageModel[]>([])
const sendMessage = useCallback(
(input: string) => {
const botMessageId = uuid()
setMessages((draft) => {
draft.push({ id: uuid(), text: input, author: 'user' }, { id: botMessageId, text: '...', author: botId })
})
bot.sendMessage({
prompt: input,
onEvent(event) {
if (event.type === 'UPDATE_ANSWER') {
setMessages((draft) => {
const message = draft.find((m) => m.id === botMessageId)
if (message) {
message.text = event.data.text
}
})
}
},
})
},
[bot, botId, setMessages],
)
return { messages, sendMessage }
}

8
src/app/main.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { RouterProvider } from '@tanstack/react-router'
import { createRoot } from 'react-dom/client'
import '../base.css'
import { router } from './router'
const container = document.getElementById('app')!
const root = createRoot(container)
root.render(<RouterProvider router={router} />)

View File

@@ -0,0 +1,53 @@
import { Container, Input } from '@chakra-ui/react'
import { FC, useCallback } from 'react'
import { useChat } from '~app/hooks/use-chat'
import { BotId } from '../bots'
import ConversationPanel from '../components/Chat/ConversationPanel'
const MultiBotChatPanel: FC = () => {
const chatgptChat = useChat('chatgpt')
const bingChat = useChat('bing')
const onUserSendMessage = useCallback(
(input: string, botId?: BotId) => {
if (botId === 'chatgpt') {
chatgptChat.sendMessage(input)
} else if (botId === 'bing') {
bingChat.sendMessage(input)
} else {
chatgptChat.sendMessage(input)
bingChat.sendMessage(input)
}
},
[bingChat, chatgptChat],
)
const onSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const formData = new FormData(form)
const { input } = Object.fromEntries(formData.entries())
form.reset()
onUserSendMessage(input as string)
},
[onUserSendMessage],
)
return (
<main className="grid grid-cols-[1fr_2px_1fr] grid-rows-[1fr_80px] overflow-hidden">
<ConversationPanel botId="chatgpt" messages={chatgptChat.messages} onUserSendMessage={onUserSendMessage} />
<div className="bg-gray-300"></div>
<ConversationPanel botId="bing" messages={bingChat.messages} onUserSendMessage={onUserSendMessage} />
<div className="col-span-3">
<Container className="h-full">
<form onSubmit={onSubmit}>
<Input size="lg" name="input" autoComplete="off" className="shadow-[0_0_10px_rgba(0,0,0,0.10)]" />
</form>
</Container>
</div>
</main>
)
}
export default MultiBotChatPanel

View File

@@ -0,0 +1,15 @@
import { FC } from 'react'
import { useChat } from '~app/hooks/use-chat'
import { BotId } from '../bots'
import ConversationPanel from '../components/Chat/ConversationPanel'
interface Props {
botId: BotId
}
const SingleBotChatPanel: FC<Props> = ({ botId }) => {
const { messages, sendMessage } = useChat(botId)
return <ConversationPanel botId={botId} messages={messages} onUserSendMessage={sendMessage} />
}
export default SingleBotChatPanel

54
src/app/router.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { ChakraProvider } from '@chakra-ui/react'
import { Outlet, ReactRouter, RootRoute, Route, useParams } from '@tanstack/react-router'
import { BotId } from './bots'
import Sidebar from './components/Sidebar'
import MultiBotChatPanel from './pages/MultiBotChatPanel'
import SingleBotChatPanel from './pages/SingleBotChatPanel'
function Layout() {
return (
<ChakraProvider>
<div className="grid grid-cols-[200px_1fr] h-screen">
<Sidebar />
<Outlet />
</div>
</ChakraProvider>
)
}
const rootRoute = new RootRoute()
const layoutRoute = new Route({
getParentRoute: () => rootRoute,
component: Layout,
id: 'layout',
})
const indexRoute = new Route({
getParentRoute: () => layoutRoute,
path: 'src/index.html',
component: MultiBotChatPanel,
})
function ChatRoute() {
const { botId } = useParams({ from: chatRoute.id })
return <SingleBotChatPanel botId={botId as BotId} />
}
const chatRoute = new Route({
getParentRoute: () => layoutRoute,
path: 'chat/$botId',
component: ChatRoute,
})
const routeTree = rootRoute.addChildren([layoutRoute.addChildren([indexRoute, chatRoute])])
const router = new ReactRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
export { router }

BIN
src/assets/bing-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

19
src/assets/bing-logo.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg width="678" height="1024" viewBox="0 0 678 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M342.444 322.924C323.57 325.117 309.176 340.445 307.838 359.775C307.261 368.104 307.442 368.669 326.321 417.25C369.273 527.78 379.679 554.382 381.429 558.126C385.669 567.193 391.631 575.723 399.08 583.379C404.796 589.254 408.566 592.413 414.942 596.672C426.148 604.156 431.709 606.225 475.314 619.136C517.79 631.713 540.996 640.072 560.993 649.997C586.899 662.855 604.974 677.481 616.407 694.835C624.61 707.287 631.875 729.616 635.036 752.093C636.272 760.879 636.28 780.301 635.051 788.244C632.384 805.483 627.057 819.929 618.908 832.018C614.574 838.447 616.082 837.371 622.383 829.536C640.215 807.365 658.38 769.472 667.649 735.11C678.866 693.523 680.392 648.866 672.04 606.599C655.775 524.29 603.814 453.257 530.632 413.289C526.034 410.777 508.52 401.597 484.776 389.252C481.173 387.378 476.26 384.813 473.858 383.552C471.456 382.29 466.543 379.725 462.94 377.852C459.337 375.979 448.965 370.575 439.891 365.844C430.817 361.112 420.664 355.818 417.328 354.079C407.159 348.777 400.336 345.215 395.249 342.552C371.721 330.235 361.762 325.256 358.923 324.392C355.945 323.486 348.38 322.323 346.482 322.479C346.082 322.512 344.265 322.712 342.444 322.924Z" fill="url(#paint0_radial_2_17)"/>
<path d="M393.737 735.544C392.433 736.316 390.603 737.434 389.669 738.027C388.734 738.621 386.66 739.91 385.059 740.893C379.182 744.5 363.552 754.131 350.121 762.422C341.294 767.871 339.984 768.683 328.771 775.642C324.767 778.126 320.509 780.744 319.308 781.46C318.107 782.176 312.976 785.336 307.905 788.482C302.834 791.627 293.991 797.087 288.253 800.614C282.515 804.14 272.252 810.471 265.447 814.682C258.641 818.892 249.688 824.413 245.552 826.95C241.415 829.486 237.594 831.936 237.06 832.394C236.267 833.074 199.475 855.865 181.014 867.112C166.993 875.653 150.773 881.366 134.169 883.61C126.439 884.654 111.811 884.658 104.103 883.616C83.2021 880.794 63.9476 872.999 47.4576 860.687C40.9893 855.857 28.8117 843.689 24.1563 837.403C13.1855 822.592 6.08829 806.705 2.41258 788.729C1.56681 784.592 0.766658 781.099 0.635158 780.965C0.291606 780.618 0.912197 786.867 2.03165 795.037C3.19575 803.534 5.67635 815.824 8.3481 826.335C29.0233 907.68 87.8556 973.842 167.5 1005.32C190.434 1014.38 213.577 1020.09 238.758 1022.89C248.22 1023.95 275.003 1024.37 284.878 1023.62C330.165 1020.19 369.597 1006.86 410.049 981.295C413.652 979.018 420.421 974.75 425.091 971.809C429.762 968.869 435.657 965.131 438.193 963.504C440.728 961.876 443.785 959.953 444.986 959.231C446.187 958.508 448.589 956.999 450.324 955.876C452.059 954.754 459.483 950.058 466.822 945.441L496.17 926.904L506.247 920.539L506.61 920.31L507.72 919.609L508.248 919.275L515.667 914.589L541.307 898.394C573.977 877.865 583.719 870.658 598.897 855.79C605.225 849.593 614.765 839.013 615.239 837.67C615.335 837.397 617.031 834.781 619.007 831.857C627.039 819.972 632.395 805.413 635.051 788.244C636.28 780.301 636.272 760.879 635.036 752.093C632.647 735.106 627.219 715.838 621.367 703.569C611.77 683.451 591.326 665.171 561.957 650.449C553.848 646.384 545.474 642.664 544.539 642.713C544.096 642.736 516.766 659.441 483.806 679.837C450.846 700.233 422.24 717.936 420.239 719.178C418.237 720.421 414.798 722.522 412.596 723.846L393.737 735.544Z" fill="url(#paint1_radial_2_17)"/>
<path d="M0.141154 637.697L0.282367 779.752L2.12098 788.001C7.87013 813.792 17.8312 832.387 35.148 849.658C43.2933 857.782 49.5219 862.68 58.3485 867.903C77.0259 878.956 97.1276 884.409 119.146 884.399C142.207 884.387 162.156 878.635 182.713 866.07C186.182 863.95 199.775 855.581 212.919 847.472L236.817 832.729V664.186V495.643L236.81 341.457C236.805 243.089 236.625 184.67 236.314 180.087C234.354 151.286 222.309 124.809 202.055 104.782C195.839 98.6357 190.528 94.5305 174.706 83.6427C166.833 78.2244 152.421 68.2988 142.68 61.586C132.939 54.8727 116.89 43.8135 107.015 37.0094C97.1402 30.2058 83.056 20.4986 75.7167 15.4385C60.4272 4.89657 59.2306 4.16335 54.6087 2.50964C48.597 0.359048 42.2263 -0.430914 36.1695 0.223193C18.5163 2.12971 4.38462 14.8756 0.711338 32.2041C0.139722 34.9001 0.0344077 70.7794 0.027129 265.516L0.0188956 495.643H0L0.141154 637.697Z" fill="url(#paint2_linear_2_17)"/>
<defs>
<radialGradient id="paint0_radial_2_17" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(654.126 722.251) rotate(-130.909) scale(529.064 380.685)">
<stop stop-color="#00CACC"/>
<stop offset="1" stop-color="#048FCE"/>
</radialGradient>
<radialGradient id="paint1_radial_2_17" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(88.8183 915.135) rotate(-23.1954) scale(572.26 953.69)">
<stop stop-color="#00BBEC"/>
<stop offset="1" stop-color="#2756A9"/>
</radialGradient>
<linearGradient id="paint2_linear_2_17" x1="118.409" y1="0" x2="118.409" y2="884.399" gradientUnits="userSpaceOnUse">
<stop stop-color="#00BBEC"/>
<stop offset="1" stop-color="#2756A9"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

7
src/background/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import Browser from 'webextension-polyfill'
Browser.action.onClicked.addListener(() => {
Browser.tabs.create({
url: 'src/index.html',
})
})

7
src/base.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-size: 100%;
}

11
src/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ChatHub</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./app/main.tsx"></script>
</body>
</html>

10
src/types/chat.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface ChatMessageModel {
id: string
author: string
text: string
metadata?: unknown
}
export interface ConversationModel {
messages: ChatMessageModel[]
}

1
src/types/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './chat'

3
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,3 @@
export enum ErrorCode {
CONVERSATION_LIMIT = 'CONVERSATION_LIMIT',
}

21
src/utils/fetch-sse.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createParser } from 'eventsource-parser'
import { isEmpty } from 'lodash-es'
import { streamAsyncIterable } from './stream-async-iterable'
export async function fetchSSE(resource: string, options: RequestInit & { onMessage: (message: string) => void }) {
const { onMessage, ...fetchOptions } = options
const resp = await fetch(resource, fetchOptions)
if (!resp.ok) {
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
}
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
})
for await (const chunk of streamAsyncIterable(resp.body!)) {
const str = new TextDecoder().decode(chunk)
parser.feed(str)
}
}

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { v4 } from 'uuid'
export function uuid() {
return v4()
}

View File

@@ -0,0 +1,14 @@
export async function* streamAsyncIterable(stream: ReadableStream) {
const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return
}
yield value
}
} finally {
reader.releaseLock()
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import tsconfigPaths from 'vite-tsconfig-paths'
import manifest from './manifest.json'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [tsconfigPaths(), react(), crx({ manifest })],
build: {
rollupOptions: {
input: ['src/index.html'],
},
},
})

6045
yarn.lock Normal file

File diff suppressed because it is too large Load Diff