mirror of
https://github.com/chathub-dev/chathub.git
synced 2025-09-26 20:31:18 +08:00
Use chrome side panel
This commit is contained in:
@@ -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
21
sidepanel.html
Normal 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>
|
@@ -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
|
||||
|
@@ -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 (
|
||||
|
@@ -67,6 +67,8 @@ const resources = {
|
||||
'Share conversation': '分享会话',
|
||||
'Clear conversation': '清空会话',
|
||||
'View history': '查看历史消息',
|
||||
'Premium Feature': '高级功能',
|
||||
'Upgrade to unlock': '升级解锁',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
|
80
src/app/pages/SidePanelPage.tsx
Normal file
80
src/app/pages/SidePanelPage.tsx
Normal 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
43
src/app/sidepanel.tsx
Normal 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 />)
|
@@ -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')
|
||||
|
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
plugins: [tsconfigPaths(), react(), crx({ manifest })],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: ['app.html'],
|
||||
input: ['app.html', 'sidepanel.html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
Reference in New Issue
Block a user