Use chrome side panel

This commit is contained in:
wong2
2023-05-18 16:00:00 +08:00
parent bf2b9f6a16
commit 10ff0e4111
9 changed files with 177 additions and 19 deletions

View File

@@ -20,7 +20,7 @@ export default defineManifest(async (env) => {
action: {},
host_permissions: ['https://*.bing.com/', 'https://*.openai.com/', 'https://bard.google.com/'],
optional_host_permissions: ['https://*/*'],
permissions: ['storage', 'unlimitedStorage'],
permissions: ['storage', 'unlimitedStorage', 'sidePanel'],
content_scripts: [
{
matches: ['https://chat.openai.com/*'],
@@ -38,5 +38,8 @@ export default defineManifest(async (env) => {
description: 'Open ChatHub app',
},
},
side_panel: {
default_path: 'sidepanel.html',
},
}
})

21
sidepanel.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html translate="no">
<head>
<meta charset="utf-8" />
<meta content="width=device-width,initial-scale=1.0" name="viewport" />
<title>ChatHub</title>
<script type="module" src="./src/app/theme.ts"></script>
<style>
html,
body,
#app {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/app/sidepanel.tsx"></script>
</body>
</html>

View File

@@ -1,20 +1,22 @@
import cx from 'classnames'
import { useSetAtom } from 'jotai'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import clearIcon from '~/assets/icons/clear.svg'
import historyIcon from '~/assets/icons/history.svg'
import shareIcon from '~/assets/icons/share.svg'
import { CHATBOTS } from '~app/consts'
import { ConversationContext, ConversationContextValue } from '~app/context'
import { trackEvent } from '~app/plausible'
import ShareDialog from '../Share/Dialog'
import { multiPanelBotsAtom } from '~app/state'
import { ChatMessageModel } from '~types'
import { BotId } from '../../bots'
import Button from '../Button'
import HistoryDialog from '../History/Dialog'
import ShareDialog from '../Share/Dialog'
import SwitchBotDropdown from '../SwitchBotDropdown'
import ChatMessageInput from './ChatMessageInput'
import ChatMessageList from './ChatMessageList'
import { useTranslation } from 'react-i18next'
interface Props {
botId: BotId
@@ -34,6 +36,7 @@ const ConversationPanel: FC<Props> = (props) => {
const marginClass = 'mx-5'
const [showHistory, setShowHistory] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const setCompareBots = useSetAtom(multiPanelBotsAtom)
const context: ConversationContextValue = useMemo(() => {
return {
@@ -64,6 +67,21 @@ const ConversationPanel: FC<Props> = (props) => {
trackEvent('open_share_dialog', { botId: props.botId })
}, [props.botId])
const onSwitchBot = useCallback(
(botId: BotId) => {
if (props.index === undefined) {
return
}
trackEvent('switch_bot', { botId })
setCompareBots((bots) => {
const newBots = [...bots]
newBots[props.index!] = botId
return newBots
})
},
[props.index, setCompareBots],
)
return (
<ConversationContext.Provider value={context}>
<div className={cx('flex flex-col overflow-hidden bg-primary-background h-full rounded-[20px]')}>
@@ -76,7 +94,7 @@ const ConversationPanel: FC<Props> = (props) => {
<div className="flex flex-row items-center gap-2">
<img src={botInfo.avatar} className="w-5 h-5 object-contain rounded-full" />
<span className="font-semibold text-primary-text text-sm">{botInfo.name}</span>
{mode === 'compact' && <SwitchBotDropdown excludeBotId={props.botId} index={props.index!} />}
{mode === 'compact' && <SwitchBotDropdown excludeBotId={props.botId} onChange={onSwitchBot} />}
</div>
<div className="flex flex-row items-center gap-3">
<img

View File

@@ -1,30 +1,20 @@
import { Menu, Transition } from '@headlessui/react'
import { FC, Fragment, useCallback } from 'react'
import dropdownIcon from '~/assets/icons/dropdown.svg'
import { BotId } from '~app/bots'
import { CHATBOTS } from '~app/consts'
import dropdownIcon from '~/assets/icons/dropdown.svg'
import { useSetAtom } from 'jotai'
import { multiPanelBotsAtom } from '~app/state'
import { trackEvent } from '~app/plausible'
interface Props {
excludeBotId: BotId
index: number
onChange: (botId: BotId) => void
}
const SwitchBotDropdown: FC<Props> = (props) => {
const setCompareBots = useSetAtom(multiPanelBotsAtom)
const onSelect = useCallback(
(botId: BotId) => {
trackEvent('switch_bot', { botId })
setCompareBots((bots) => {
const newBots = [...bots] as [BotId, BotId]
newBots[props.index] = botId
return newBots
})
props.onChange(botId)
},
[props.index, setCompareBots],
[props],
)
return (

View File

@@ -67,6 +67,8 @@ const resources = {
'Share conversation': '分享会话',
'Clear conversation': '清空会话',
'View history': '查看历史消息',
'Premium Feature': '高级功能',
'Upgrade to unlock': '升级解锁',
},
},
de: {

View File

@@ -0,0 +1,80 @@
import cx from 'classnames'
import { useAtom } from 'jotai'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import clearIcon from '~/assets/icons/clear.svg'
import Button from '~app/components/Button'
import ChatMessageInput from '~app/components/Chat/ChatMessageInput'
import ChatMessageList from '~app/components/Chat/ChatMessageList'
import SwitchBotDropdown from '~app/components/SwitchBotDropdown'
import { CHATBOTS } from '~app/consts'
import { ConversationContext, ConversationContextValue } from '~app/context'
import { useChat } from '~app/hooks/use-chat'
import { sidePanelBotAtom } from '~app/state'
function SidePanelPage() {
const { t } = useTranslation()
const [botId, setBotId] = useAtom(sidePanelBotAtom)
const botInfo = CHATBOTS[botId]
const chat = useChat(botId)
const onSubmit = useCallback(
async (input: string) => {
chat.sendMessage(input)
},
[chat],
)
const resetConversation = useCallback(() => {
if (!chat.generating) {
chat.resetConversation()
}
}, [chat])
const context: ConversationContextValue = useMemo(() => {
return {
reset: resetConversation,
}
}, [resetConversation])
return (
<ConversationContext.Provider value={context}>
<div className="flex flex-col overflow-hidden bg-primary-background h-full">
<div className="border-b border-solid border-primary-border flex flex-row items-center justify-between gap-2 py-3 mx-5">
<div className="flex flex-row items-center gap-2">
<img src={botInfo.avatar} className="w-4 h-4 object-contain rounded-full" />
<span className="font-semibold text-primary-text text-xs">{botInfo.name}</span>
<SwitchBotDropdown excludeBotId={botId} onChange={setBotId} />
</div>
<div className="flex flex-row items-center gap-3">
<img
src={clearIcon}
className={cx('w-4 h-4', chat.generating ? 'cursor-not-allowed' : 'cursor-pointer')}
onClick={resetConversation}
/>
</div>
</div>
<ChatMessageList botId={botId} messages={chat.messages} className="mx-5" />
<div className="flex flex-col mx-5 my-3 gap-3">
<hr className="grow border-primary-border" />
<ChatMessageInput
mode="full"
disabled={chat.generating}
autoFocus={true}
placeholder="Ask me anything..."
onSubmit={onSubmit}
actionButton={
chat.generating ? (
<Button text={t('Stop')} color="flat" size="small" onClick={chat.stopGenerating} />
) : (
<Button text={t('Send')} color="primary" type="submit" size="small" />
)
}
/>
</div>
</div>
</ConversationContext.Provider>
)
}
export default SidePanelPage

43
src/app/sidepanel.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react'
import { createRoot } from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import Browser from 'webextension-polyfill'
import premiumIcon from '~/assets/icons/premium.svg'
import './base.scss'
import Button from './components/Button'
import { usePremium } from './hooks/use-premium'
import './i18n'
import SidePanelPage from './pages/SidePanelPage'
import { trackEvent } from './plausible'
function PremiumOnly() {
const { t } = useTranslation()
const openPremiumPage = useCallback(() => {
trackEvent('open_premium_from_sidepanel')
window.open(Browser.runtime.getURL('app.html#/premium'), '_blank')
}, [])
return (
<div className="w-full h-full flex flex-col justify-center items-center gap-3">
<img src={premiumIcon} className="w-10 h-10" />
<div className="text-xl font-bold">{t('Premium Feature')}</div>
<Button text={t('Upgrade to unlock')} color="primary" onClick={openPremiumPage} />
</div>
)
}
function SidePanelApp() {
const premiumState = usePremium()
if (premiumState.isLoading) {
return null
}
if (premiumState.activated) {
return <SidePanelPage />
}
return <PremiumOnly />
}
const container = document.getElementById('app')!
const root = createRoot(container)
root.render(<SidePanelApp />)

View File

@@ -27,3 +27,4 @@ export const licenseKeyAtom = atomWithStorage('licenseKey', '')
export const sidebarCollapsedAtom = atomWithStorage('sidebarCollapsed', false)
export const themeColorAtom = atomWithStorage('themeColor', getDefaultThemeColor())
export const followArcThemeAtom = atomWithStorage('followArcTheme', false)
export const sidePanelBotAtom = atomWithStorage<BotId>('sidePanelBot', 'chatgpt')

View File

@@ -9,7 +9,7 @@ export default defineConfig({
plugins: [tsconfigPaths(), react(), crx({ manifest })],
build: {
rollupOptions: {
input: ['app.html'],
input: ['app.html', 'sidepanel.html'],
},
},
})