├── .gitattributes ├── .prettierignore ├── src ├── pages │ ├── styles.scss │ └── IndependentPanel │ │ ├── index.html │ │ ├── index.jsx │ │ └── styles.scss ├── logo.png ├── utils │ ├── is-edge.mjs │ ├── is-safari.mjs │ ├── is-firefox.mjs │ ├── get-client-position.mjs │ ├── ends-with-question-mark.mjs │ ├── parse-int-with-clamp.mjs │ ├── parse-float-with-clamp.mjs │ ├── change-children-font-size.mjs │ ├── open-url.mjs │ ├── create-element-at-position.mjs │ ├── set-element-position-in-viewport.mjs │ ├── get-possible-element-by-query-selector.mjs │ ├── get-conversation-pairs.mjs │ ├── update-ref-height.mjs │ ├── wait-for-element-to-exist-and-select.mjs │ ├── limited-fetch.mjs │ ├── index.mjs │ ├── fetch-bg.mjs │ ├── jwt-token-generator.mjs │ ├── fetch-sse.mjs │ ├── is-mobile.mjs │ ├── get-core-content-text.mjs │ ├── crop-text.mjs │ └── eventsource-parser.mjs ├── services │ ├── clients │ │ ├── poe │ │ │ ├── graphql │ │ │ │ ├── ChatAddedSubscription.graphql │ │ │ │ ├── SummarizePlainPostQuery.graphql │ │ │ │ ├── ChatFragment.graphql │ │ │ │ ├── BioFragment.graphql │ │ │ │ ├── HandleFragment.graphql │ │ │ │ ├── MessageAddedSubscription.graphql │ │ │ │ ├── ViewerStateUpdatedSubscription.graphql │ │ │ │ ├── SummarizeQuotePostQuery.graphql │ │ │ │ ├── MessageDeletedSubscription.graphql │ │ │ │ ├── ChatViewQuery.graphql │ │ │ │ ├── MessageRemoveVoteMutation.graphql │ │ │ │ ├── StaleChatUpdateMutation.graphql │ │ │ │ ├── DeleteHumanMessagesMutation.graphql │ │ │ │ ├── SummarizeSharePostQuery.graphql │ │ │ │ ├── AutoSubscriptionMutation.graphql │ │ │ │ ├── MessageSetVoteMutation.graphql │ │ │ │ ├── MessageFragment.graphql │ │ │ │ ├── ShareMessagesMutation.graphql │ │ │ │ ├── SendVerificationCodeForLoginMutation.graphql │ │ │ │ ├── LoginWithVerificationCodeMutation.graphql │ │ │ │ ├── SignupWithVerificationCodeMutation.graphql │ │ │ │ ├── UserSnippetFragment.graphql │ │ │ │ ├── AddMessageBreakMutation.graphql │ │ │ │ ├── ViewerInfoQuery.graphql │ │ │ │ ├── ChatPaginationQuery.graphql │ │ │ │ ├── ViewerStateFragment.graphql │ │ │ │ └── AddHumanMessageMutation.graphql │ │ │ └── websocket.js │ │ └── bard │ │ │ └── index.mjs │ ├── apis │ │ ├── aiml-api.mjs │ │ ├── deepseek-api.mjs │ │ ├── openrouter-api.mjs │ │ ├── moonshot-api.mjs │ │ ├── chatglm-api.mjs │ │ ├── bard-web.mjs │ │ ├── ollama-api.mjs │ │ ├── poe-web.mjs │ │ ├── claude-web.mjs │ │ ├── shared.mjs │ │ ├── claude-api.mjs │ │ ├── waylaidwanderer-api.mjs │ │ ├── azure-openai-api.mjs │ │ ├── bing-web.mjs │ │ └── custom-api.mjs │ ├── local-session.mjs │ ├── init-session.mjs │ └── wrappers.mjs ├── _locales │ ├── i18n.mjs │ ├── i18n-react.mjs │ └── resources.mjs ├── fonts │ ├── SLXVc1nY6HkvangtZmpQdkhzfH5lkSscRiyS.woff2 │ ├── SLXVc1nY6HkvangtZmpQdkhzfH5lkSscQyyS4J0.woff2 │ ├── SLXVc1nY6HkvangtZmpQdkhzfH5lkSscSCyS4J0.woff2 │ └── styles.css ├── hooks │ ├── use-theme.mjs │ ├── use-clamp-window-size.mjs │ ├── use-window-size.mjs │ ├── use-window-theme.mjs │ └── use-config.mjs ├── components │ ├── index.mjs │ ├── CopyButton │ │ └── index.jsx │ ├── MarkdownRender │ │ ├── Hyperlink.jsx │ │ ├── Pre.jsx │ │ ├── markdown-without-katex.jsx │ │ └── markdown.jsx │ ├── ConfirmButton │ │ └── index.jsx │ ├── DeleteButton │ │ └── index.jsx │ ├── FeedbackForChatGPTWeb │ │ └── index.jsx │ ├── ReadButton │ │ └── index.jsx │ ├── WebJumpBackNotification │ │ └── index.jsx │ ├── InputBox │ │ └── index.jsx │ ├── DecisionCard │ │ └── index.jsx │ └── ConversationItem │ │ └── index.jsx ├── popup │ ├── index.html │ ├── sections │ │ ├── SiteAdapters.jsx │ │ ├── ModulesPart.jsx │ │ └── FeaturePages.jsx │ ├── index.jsx │ ├── styles.scss │ └── Popup.jsx ├── content-script │ ├── site-adapters │ │ ├── duckduckgo │ │ │ └── index.mjs │ │ ├── brave │ │ │ └── index.mjs │ │ ├── baidu │ │ │ └── index.mjs │ │ ├── quora │ │ │ └── index.mjs │ │ ├── reddit │ │ │ └── index.mjs │ │ ├── arxiv │ │ │ └── index.mjs │ │ ├── juejin │ │ │ └── index.mjs │ │ ├── weixin │ │ │ └── index.mjs │ │ ├── followin │ │ │ └── index.mjs │ │ ├── stackoverflow │ │ │ └── index.mjs │ │ ├── zhihu │ │ │ └── index.mjs │ │ ├── gitlab │ │ │ └── index.mjs │ │ ├── bilibili │ │ │ └── index.mjs │ │ └── youtube │ │ │ └── index.mjs │ ├── menu-tools │ │ └── index.mjs │ └── selection-tools │ │ └── index.mjs ├── background │ ├── commands.mjs │ └── menus.mjs ├── config │ └── language.mjs ├── rules.json ├── manifest.v2.json └── manifest.json ├── .gitignore ├── .github ├── CONTRIBUTING.md ├── dependabot.yml ├── workflows │ ├── verify-configs.yml │ ├── pr-tests.yml │ ├── pre-release-build.yml │ └── tagged-release.yml └── ISSUE_TEMPLATE │ ├── feature-request---新功能请求.md │ └── bug-report---问题报告.md ├── screenshots ├── preview_youtube.jpg ├── preview_settings.jpg ├── preview_independentpanel.jpg ├── preview_github_rightclickmenu.jpg ├── preview_reddit_selectiontools.jpg └── preview_google_floatingwindow_conversationbranch.jpg ├── safari ├── project.patch ├── appdmg.json ├── export-options.plist ├── project.pre.patch └── build.sh ├── .prettierrc ├── .eslintrc.json ├── LICENSE ├── package.json └── CURRENT_CHANGE.md /.gitattributes: -------------------------------------------------------------------------------- 1 | src/services/clients/** linguist-vendored 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | src/manifest.json 3 | src/manifest.v2.json -------------------------------------------------------------------------------- /src/pages/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'IndependentPanel/styles.scss'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | .DS_Store 6 | *.zip 7 | -------------------------------------------------------------------------------- /src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/src/logo.png -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See https://github.com/ChatGPTBox-dev/chatGPTBox/wiki/Development&Contributing -------------------------------------------------------------------------------- /screenshots/preview_youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_youtube.jpg -------------------------------------------------------------------------------- /src/utils/is-edge.mjs: -------------------------------------------------------------------------------- 1 | export function isEdge() { 2 | return navigator.userAgent.toLowerCase().includes('edg') 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/is-safari.mjs: -------------------------------------------------------------------------------- 1 | export function isSafari() { 2 | return navigator.vendor === 'Apple Computer, Inc.' 3 | } 4 | -------------------------------------------------------------------------------- /screenshots/preview_settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_settings.jpg -------------------------------------------------------------------------------- /src/utils/is-firefox.mjs: -------------------------------------------------------------------------------- 1 | export function isFirefox() { 2 | return navigator.userAgent.toLowerCase().includes('firefox') 3 | } 4 | -------------------------------------------------------------------------------- /screenshots/preview_independentpanel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_independentpanel.jpg -------------------------------------------------------------------------------- /screenshots/preview_github_rightclickmenu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_github_rightclickmenu.jpg -------------------------------------------------------------------------------- /screenshots/preview_reddit_selectiontools.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_reddit_selectiontools.jpg -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ChatAddedSubscription.graphql: -------------------------------------------------------------------------------- 1 | subscription ChatAddedSubscription { 2 | chatAdded { 3 | ...ChatFragment 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/_locales/i18n.mjs: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import { resources } from './resources' 3 | 4 | i18n.init({ 5 | resources, 6 | fallbackLng: 'en', 7 | }) 8 | -------------------------------------------------------------------------------- /src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscRiyS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscRiyS.woff2 -------------------------------------------------------------------------------- /src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscQyyS4J0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscQyyS4J0.woff2 -------------------------------------------------------------------------------- /src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscSCyS4J0.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/src/fonts/SLXVc1nY6HkvangtZmpQdkhzfH5lkSscSCyS4J0.woff2 -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/SummarizePlainPostQuery.graphql: -------------------------------------------------------------------------------- 1 | query SummarizePlainPostQuery($comment: String!) { 2 | summarizePlainPost(comment: $comment) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/get-client-position.mjs: -------------------------------------------------------------------------------- 1 | export function getClientPosition(e) { 2 | const rect = e.getBoundingClientRect() 3 | return { x: rect.left, y: rect.top } 4 | } 5 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ChatFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment ChatFragment on Chat { 2 | id 3 | chatId 4 | defaultBotNickname 5 | shouldShowDisclaimer 6 | } 7 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/BioFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment BioFragment on Viewer { 2 | id 3 | poeUser { 4 | id 5 | uid 6 | bio 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /safari/project.patch: -------------------------------------------------------------------------------- 1 | --- a/build/safari/Fission - ChatBox/Fission - ChatBox.xcodeproj/project.pbxproj 2 | +++ b/build/safari/Fission - ChatBox/Fission - ChatBox.xcodeproj/project.pbxproj 3 | -------------------------------------------------------------------------------- /screenshots/preview_google_floatingwindow_conversationbranch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChatGPTBox-dev/chatGPTBox/HEAD/screenshots/preview_google_floatingwindow_conversationbranch.jpg -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/HandleFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment HandleFragment on Viewer { 2 | id 3 | poeUser { 4 | id 5 | uid 6 | handle 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/MessageAddedSubscription.graphql: -------------------------------------------------------------------------------- 1 | subscription MessageAddedSubscription($chatId: BigInt!) { 2 | messageAdded(chatId: $chatId) { 3 | ...MessageFragment 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ViewerStateUpdatedSubscription.graphql: -------------------------------------------------------------------------------- 1 | subscription ViewerStateUpdatedSubscription { 2 | viewerStateUpdated { 3 | ...ViewerStateFragment 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/SummarizeQuotePostQuery.graphql: -------------------------------------------------------------------------------- 1 | query SummarizeQuotePostQuery($comment: String, $quotedPostId: BigInt!) { 2 | summarizeQuotePost(comment: $comment, quotedPostId: $quotedPostId) 3 | } 4 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/MessageDeletedSubscription.graphql: -------------------------------------------------------------------------------- 1 | subscription MessageDeletedSubscription($chatId: BigInt!) { 2 | messageDeleted(chatId: $chatId) { 3 | id 4 | messageId 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore" 9 | include: "scope" -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ChatViewQuery.graphql: -------------------------------------------------------------------------------- 1 | query ChatViewQuery($bot: String!) { 2 | chatOfBot(bot: $bot) { 3 | id 4 | chatId 5 | defaultBotNickname 6 | shouldShowDisclaimer 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/MessageRemoveVoteMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation MessageRemoveVoteMutation($messageId: BigInt!) { 2 | messageRemoveVote(messageId: $messageId) { 3 | message { 4 | ...MessageFragment 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/StaleChatUpdateMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation StaleChatUpdateMutation($chatId: BigInt!) { 2 | staleChatUpdate(chatId: $chatId) { 3 | message { 4 | ...MessageFragment 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/DeleteHumanMessagesMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation DeleteHumanMessagesMutation($messageIds: [BigInt!]!) { 2 | messagesDelete(messageIds: $messageIds) { 3 | viewer { 4 | id 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/SummarizeSharePostQuery.graphql: -------------------------------------------------------------------------------- 1 | query SummarizeSharePostQuery($comment: String!, $chatId: BigInt!, $messageIds: [BigInt!]!) { 2 | summarizeSharePost(comment: $comment, chatId: $chatId, messageIds: $messageIds) 3 | } 4 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/AutoSubscriptionMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation AutoSubscriptionMutation($subscriptions: [AutoSubscriptionQuery!]!) { 2 | autoSubscribe(subscriptions: $subscriptions) { 3 | viewer { 4 | id 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /safari/appdmg.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Fission - ChatBox", 3 | "icon": "../src/logo.png", 4 | "contents": [ 5 | { "x": 448, "y": 344, "type": "link", "path": "/Applications" }, 6 | { "x": 192, "y": 344, "type": "file", "path": "../build/Fission - ChatBox.app" } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /safari/export-options.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | mac-application 7 | 8 | -------------------------------------------------------------------------------- /src/utils/ends-with-question-mark.mjs: -------------------------------------------------------------------------------- 1 | export function endsWithQuestionMark(question) { 2 | return ( 3 | question.endsWith('?') || // ASCII 4 | question.endsWith('?') || // Chinese/Japanese 5 | question.endsWith('؟') || // Arabic 6 | question.endsWith('⸮') // Arabic 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/MessageSetVoteMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation MessageSetVoteMutation($messageId: BigInt!, $voteType: VoteType!, $reason: String) { 2 | messageSetVote(messageId: $messageId, voteType: $voteType, reason: $reason) { 3 | message { 4 | ...MessageFragment 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/parse-int-with-clamp.mjs: -------------------------------------------------------------------------------- 1 | export function parseIntWithClamp(value, defaultValue, min, max) { 2 | value = parseInt(value) 3 | 4 | if (isNaN(value)) value = defaultValue 5 | else if (value > max) value = max 6 | else if (value < min) value = min 7 | 8 | return value 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/parse-float-with-clamp.mjs: -------------------------------------------------------------------------------- 1 | export function parseFloatWithClamp(value, defaultValue, min, max) { 2 | value = parseFloat(value) 3 | 4 | if (isNaN(value)) value = defaultValue 5 | else if (value > max) value = max 6 | else if (value < min) value = min 7 | 8 | return value 9 | } 10 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/MessageFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment MessageFragment on Message { 2 | id 3 | __typename 4 | messageId 5 | text 6 | linkifiedText 7 | authorNickname 8 | state 9 | vote 10 | voteReason 11 | creationTime 12 | suggestedReplies 13 | } 14 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ShareMessagesMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation ShareMessagesMutation( 2 | $chatId: BigInt! 3 | $messageIds: [BigInt!]! 4 | $comment: String 5 | ) { 6 | messagesShare(chatId: $chatId, messageIds: $messageIds, comment: $comment) { 7 | shareCode 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/use-theme.mjs: -------------------------------------------------------------------------------- 1 | import { useConfig } from './use-config.mjs' 2 | import { useWindowTheme } from './use-window-theme.mjs' 3 | 4 | export function useTheme() { 5 | const config = useConfig() 6 | const theme = useWindowTheme() 7 | return [config.themeMode === 'auto' ? theme : config.themeMode, config] 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/change-children-font-size.mjs: -------------------------------------------------------------------------------- 1 | export function changeChildrenFontSize(element, size) { 2 | try { 3 | element.style.fontSize = size 4 | } catch { 5 | /* empty */ 6 | } 7 | for (let i = 0; i < element.childNodes.length; i++) { 8 | changeChildrenFontSize(element.childNodes[i], size) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "bracketSpacing": true, 8 | "overrides": [ 9 | { 10 | "files": ".prettierrc", 11 | "options": { 12 | "parser": "json" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/_locales/i18n-react.mjs: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import { initReactI18next } from 'react-i18next' 3 | import { resources } from './resources' 4 | 5 | i18n.use(initReactI18next).init({ 6 | resources, 7 | fallbackLng: 'en', 8 | interpolation: { 9 | escapeValue: false, // not needed for react as it escapes by default 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/utils/open-url.mjs: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | 3 | export function openUrl(url) { 4 | Browser.tabs.query({ url, currentWindow: true }).then((tabs) => { 5 | if (tabs.length > 0) { 6 | Browser.tabs.update(tabs[0].id, { active: true }) 7 | } else { 8 | Browser.tabs.create({ url }) 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/index.mjs: -------------------------------------------------------------------------------- 1 | export * from './ConfirmButton' 2 | export * from './ConversationCard' 3 | export * from './ConversationItem' 4 | export * from './CopyButton' 5 | export * from './DecisionCard' 6 | export * from './DeleteButton' 7 | export * from './FeedbackForChatGPTWeb' 8 | export * from './FloatingToolbar' 9 | export * from './InputBox' 10 | export * from './ReadButton' 11 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/SendVerificationCodeForLoginMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation SendVerificationCodeForLoginMutation( 2 | $emailAddress: String 3 | $phoneNumber: String 4 | ) { 5 | sendVerificationCode( 6 | verificationReason: login 7 | emailAddress: $emailAddress 8 | phoneNumber: $phoneNumber 9 | ) { 10 | status 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/create-element-at-position.mjs: -------------------------------------------------------------------------------- 1 | export function createElementAtPosition(x = 0, y = 0, zIndex = 2147483647) { 2 | const element = document.createElement('div') 3 | element.style.position = 'fixed' 4 | element.style.zIndex = zIndex 5 | element.style.left = x + 'px' 6 | element.style.top = y + 'px' 7 | document.documentElement.appendChild(element) 8 | return element 9 | } 10 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ChatGPTBox 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/verify-configs.yml: -------------------------------------------------------------------------------- 1 | name: verify-configs 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 6 * * *" 6 | 7 | jobs: 8 | verify_configs: 9 | runs-on: ubuntu-22.04 10 | 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-node@v5 14 | with: 15 | node-version: 20 16 | - run: npm ci 17 | - run: npm run verify 18 | -------------------------------------------------------------------------------- /src/utils/set-element-position-in-viewport.mjs: -------------------------------------------------------------------------------- 1 | export function setElementPositionInViewport(element, x = 0, y = 0) { 2 | const retX = Math.min(Math.max(0, window.innerWidth - element.offsetWidth), Math.max(0, x)) 3 | const retY = Math.min(Math.max(0, window.innerHeight - element.offsetHeight), Math.max(0, y)) 4 | element.style.left = retX + 'px' 5 | element.style.top = retY + 'px' 6 | return { x: retX, y: retY } 7 | } 8 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/duckduckgo/index.mjs: -------------------------------------------------------------------------------- 1 | import { waitForElementToExistAndSelect } from '../../../utils/index.mjs' 2 | import { config } from '../index' 3 | 4 | export default { 5 | init: async (hostname, userConfig) => { 6 | if (userConfig.insertAtTop) { 7 | return !!(await waitForElementToExistAndSelect(config.duckduckgo.resultsContainerQuery[0], 5)) 8 | } 9 | return true 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/use-clamp-window-size.mjs: -------------------------------------------------------------------------------- 1 | import { useWindowSize } from './use-window-size.mjs' 2 | 3 | export function useClampWindowSize(widthRange = [0, Infinity], heightRange = [0, Infinity]) { 4 | const windowSize = useWindowSize() 5 | windowSize[0] = Math.min(widthRange[1], Math.max(windowSize[0], widthRange[0])) 6 | windowSize[1] = Math.min(heightRange[1], Math.max(windowSize[1], heightRange[0])) 7 | return windowSize 8 | } 9 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/LoginWithVerificationCodeMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation LoginWithVerificationCodeMutation( 2 | $verificationCode: String! 3 | $emailAddress: String 4 | $phoneNumber: String 5 | ) { 6 | loginWithVerificationCode( 7 | verificationCode: $verificationCode 8 | emailAddress: $emailAddress 9 | phoneNumber: $phoneNumber 10 | ) { 11 | status 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/SignupWithVerificationCodeMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation SignupWithVerificationCodeMutation( 2 | $verificationCode: String! 3 | $emailAddress: String 4 | $phoneNumber: String 5 | ) { 6 | signupWithVerificationCode( 7 | verificationCode: $verificationCode 8 | emailAddress: $emailAddress 9 | phoneNumber: $phoneNumber 10 | ) { 11 | status 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/get-possible-element-by-query-selector.mjs: -------------------------------------------------------------------------------- 1 | export function getPossibleElementByQuerySelector(queryArray) { 2 | if (!queryArray) return 3 | for (const query of queryArray) { 4 | if (query) { 5 | try { 6 | const element = document.querySelector(query) 7 | if (element) { 8 | return element 9 | } 10 | } catch (e) { 11 | /* empty */ 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/brave/index.mjs: -------------------------------------------------------------------------------- 1 | import { waitForElementToExistAndSelect } from '../../../utils' 2 | import { config } from '../index.mjs' 3 | 4 | export default { 5 | init: async (hostname, userConfig) => { 6 | const selector = userConfig.insertAtTop 7 | ? config.brave.resultsContainerQuery[0] 8 | : config.brave.sidebarContainerQuery[0] 9 | await waitForElementToExistAndSelect(selector, 5) 10 | return true 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/UserSnippetFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment UserSnippetFragment on PoeUser { 2 | id 3 | uid 4 | bio 5 | handle 6 | fullName 7 | viewerIsFollowing 8 | isPoeOnlyUser 9 | profilePhotoURLTiny: profilePhotoUrl(size: tiny) 10 | profilePhotoURLSmall: profilePhotoUrl(size: small) 11 | profilePhotoURLMedium: profilePhotoUrl(size: medium) 12 | profilePhotoURLLarge: profilePhotoUrl(size: large) 13 | isFollowable 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/IndependentPanel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ChatGPTBox 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/services/apis/aiml-api.mjs: -------------------------------------------------------------------------------- 1 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 2 | 3 | /** 4 | * @param {Browser.Runtime.Port} port 5 | * @param {string} question 6 | * @param {Session} session 7 | * @param {string} apiKey 8 | */ 9 | export async function generateAnswersWithAimlApi(port, question, session, apiKey) { 10 | const baseUrl = 'https://api.aimlapi.com/v1' 11 | return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) 12 | } 13 | -------------------------------------------------------------------------------- /src/services/apis/deepseek-api.mjs: -------------------------------------------------------------------------------- 1 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 2 | 3 | /** 4 | * @param {Browser.Runtime.Port} port 5 | * @param {string} question 6 | * @param {Session} session 7 | * @param {string} apiKey 8 | */ 9 | export async function generateAnswersWithDeepSeekApi(port, question, session, apiKey) { 10 | const baseUrl = 'https://api.deepseek.com' 11 | return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) 12 | } 13 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/AddMessageBreakMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation AddMessageBreakMutation($chatId: BigInt!) { 2 | messageBreakCreate(chatId: $chatId) { 3 | message { 4 | id 5 | __typename 6 | messageId 7 | text 8 | linkifiedText 9 | authorNickname 10 | state 11 | vote 12 | voteReason 13 | creationTime 14 | suggestedReplies 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/services/apis/openrouter-api.mjs: -------------------------------------------------------------------------------- 1 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 2 | 3 | /** 4 | * @param {Browser.Runtime.Port} port 5 | * @param {string} question 6 | * @param {Session} session 7 | * @param {string} apiKey 8 | */ 9 | export async function generateAnswersWithOpenRouterApi(port, question, session, apiKey) { 10 | const baseUrl = 'https://openrouter.ai/api/v1' 11 | return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) 12 | } 13 | -------------------------------------------------------------------------------- /src/services/apis/moonshot-api.mjs: -------------------------------------------------------------------------------- 1 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 2 | 3 | /** 4 | * @param {Browser.Runtime.Port} port 5 | * @param {string} question 6 | * @param {Session} session 7 | * @param {string} apiKey 8 | */ 9 | export async function generateAnswersWithMoonshotCompletionApi(port, question, session, apiKey) { 10 | const baseUrl = 'https://api.moonshot.cn/v1' 11 | return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/pr-tests.yml: -------------------------------------------------------------------------------- 1 | name: pr-tests 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - "opened" 7 | - "reopened" 8 | - "synchronize" 9 | paths: 10 | - "src/**" 11 | - "build.mjs" 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-22.04 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | - uses: actions/setup-node@v5 20 | with: 21 | node-version: 20 22 | - run: npm ci 23 | - run: npm run lint 24 | - run: npm run build -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended"], 7 | "overrides": [], 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "react/react-in-jsx-scope": "off" 14 | }, 15 | "ignorePatterns": ["build/**", "build.mjs", "src/utils/is-mobile.mjs"], 16 | "settings": { 17 | "react": { 18 | "version": "detect" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ViewerInfoQuery.graphql: -------------------------------------------------------------------------------- 1 | query ViewerInfoQuery { 2 | viewer { 3 | id 4 | uid 5 | ...ViewerStateFragment 6 | ...BioFragment 7 | ...HandleFragment 8 | hasCompletedMultiplayerNux 9 | poeUser { 10 | id 11 | ...UserSnippetFragment 12 | } 13 | messageLimit{ 14 | canSend 15 | numMessagesRemaining 16 | resetTime 17 | shouldShowReminder 18 | } 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/utils/get-conversation-pairs.mjs: -------------------------------------------------------------------------------- 1 | export function getConversationPairs(records, isCompletion) { 2 | let pairs 3 | if (isCompletion) { 4 | pairs = '' 5 | for (const record of records) { 6 | pairs += 'Human: ' + record.question + '\nAI: ' + record.answer + '\n' 7 | } 8 | } else { 9 | pairs = [] 10 | for (const record of records) { 11 | pairs.push({ role: 'user', content: record['question'] }) 12 | pairs.push({ role: 'assistant', content: record['answer'] }) 13 | } 14 | } 15 | 16 | return pairs 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/update-ref-height.mjs: -------------------------------------------------------------------------------- 1 | export function updateRefHeight(ref) { 2 | ref.current.style.height = 'auto' 3 | const computed = window.getComputedStyle(ref.current) 4 | const height = 5 | parseInt(computed.getPropertyValue('border-top-width'), 10) + 6 | parseInt(computed.getPropertyValue('padding-top'), 10) + 7 | ref.current.scrollHeight + 8 | parseInt(computed.getPropertyValue('padding-bottom'), 10) + 9 | parseInt(computed.getPropertyValue('border-bottom-width'), 10) 10 | ref.current.style.height = `${height}px` 11 | } 12 | -------------------------------------------------------------------------------- /safari/project.pre.patch: -------------------------------------------------------------------------------- 1 | --- a/src/manifest.v2.json 2 | +++ b/src/manifest.v2.json 3 | @@ -1,5 +1,5 @@ 4 | { 5 | - "name": "ChatGPTBox", 6 | + "name": "Fission - ChatBox", 7 | "description": "Integrating ChatGPT into your browser deeply, everything you need is here", 8 | "version": "0.0.0", 9 | "manifest_version": 2, 10 | @@ -28,7 +28,7 @@ 11 | "scripts": [ 12 | "background.js" 13 | ], 14 | - "persistent": true 15 | + "persistent": true 16 | }, 17 | "browser_action": { 18 | "default_popup": "popup.html?popup=true" 19 | -------------------------------------------------------------------------------- /src/hooks/use-window-size.mjs: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/19014250/rerender-view-on-browser-resize-with-react 2 | 3 | import { useLayoutEffect, useState } from 'react' 4 | 5 | export function useWindowSize() { 6 | const [size, setSize] = useState([0, 0]) 7 | useLayoutEffect(() => { 8 | function updateSize() { 9 | setSize([window.innerWidth, window.innerHeight]) 10 | } 11 | window.addEventListener('resize', updateSize) 12 | updateSize() 13 | return () => window.removeEventListener('resize', updateSize) 14 | }, []) 15 | return size 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request---新功能请求.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request / 新功能请求 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | **新功能是否与解决某个问题相关, 请描述** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | **你期望的新功能实现方案** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Additional context** 19 | **其他** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/services/apis/chatglm-api.mjs: -------------------------------------------------------------------------------- 1 | import { getUserConfig } from '../../config/index.mjs' 2 | // import { getToken } from '../../utils/jwt-token-generator.mjs' 3 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 4 | 5 | /** 6 | * @param {Runtime.Port} port 7 | * @param {string} question 8 | * @param {Session} session 9 | */ 10 | export async function generateAnswersWithChatGLMApi(port, question, session) { 11 | const baseUrl = 'https://open.bigmodel.cn/api/paas/v4' 12 | const config = await getUserConfig() 13 | return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, config.chatglmApiKey) 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/IndependentPanel/index.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact' 2 | import '../../_locales/i18n-react' 3 | import App from './App' 4 | import Browser from 'webextension-polyfill' 5 | import { changeLanguage } from 'i18next' 6 | import { getPreferredLanguageKey } from '../../config/index.mjs' 7 | 8 | document.body.style.margin = 0 9 | document.body.style.overflow = 'hidden' 10 | getPreferredLanguageKey().then((lang) => { 11 | changeLanguage(lang) 12 | }) 13 | Browser.runtime.onMessage.addListener(async (message) => { 14 | if (message.type === 'CHANGE_LANG') { 15 | const data = message.data 16 | changeLanguage(data.lang) 17 | } 18 | }) 19 | render(, document.getElementById('app')) 20 | -------------------------------------------------------------------------------- /src/hooks/use-window-theme.mjs: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export function useWindowTheme() { 4 | const [theme, setTheme] = useState( 5 | window.matchMedia 6 | ? window.matchMedia('(prefers-color-scheme: dark)').matches 7 | ? 'dark' 8 | : 'light' 9 | : 'light', 10 | ) 11 | useEffect(() => { 12 | if (!window.matchMedia) return 13 | const listener = (e) => { 14 | setTheme(e.matches ? 'dark' : 'light') 15 | } 16 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener) 17 | return () => 18 | window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener) 19 | }, []) 20 | return theme 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/wait-for-element-to-exist-and-select.mjs: -------------------------------------------------------------------------------- 1 | export function waitForElementToExistAndSelect(selector, timeout = 0) { 2 | return new Promise((resolve) => { 3 | if (document.querySelector(selector)) { 4 | return resolve(document.querySelector(selector)) 5 | } 6 | 7 | const observer = new MutationObserver(() => { 8 | if (document.querySelector(selector)) { 9 | resolve(document.querySelector(selector)) 10 | observer.disconnect() 11 | } 12 | }) 13 | 14 | observer.observe(document.body, { 15 | subtree: true, 16 | childList: true, 17 | }) 18 | 19 | if (timeout) 20 | setTimeout(() => { 21 | observer.disconnect() 22 | resolve(null) 23 | }, timeout) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /safari/build.sh: -------------------------------------------------------------------------------- 1 | git apply safari/project.pre.patch 2 | npm run build 3 | xcrun safari-web-extension-converter ./build/firefox \ 4 | --project-location ./build/safari --app-name "Fission - ChatBox" \ 5 | --bundle-identifier dev.josStorer.chatGPTBox --force --no-prompt --no-open 6 | git apply safari/project.patch 7 | xcodebuild archive -project "./build/safari/Fission - ChatBox/Fission - ChatBox.xcodeproj" \ 8 | -scheme "Fission - ChatBox (macOS)" -configuration Release -archivePath "./build/safari/Fission - ChatBox.xcarchive" 9 | xcodebuild -exportArchive -archivePath "./build/safari/Fission - ChatBox.xcarchive" \ 10 | -exportOptionsPlist ./safari/export-options.plist -exportPath ./build 11 | npm install -D appdmg 12 | rm ./build/safari.dmg 13 | appdmg ./safari/appdmg.json ./build/safari.dmg -------------------------------------------------------------------------------- /src/utils/limited-fetch.mjs: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/64304365/stop-request-after-x-amount-is-fetched 2 | 3 | export async function limitedFetch(url, maxBytes) { 4 | return new Promise((resolve, reject) => { 5 | try { 6 | const xhr = new XMLHttpRequest() 7 | xhr.onprogress = (ev) => { 8 | if (ev.loaded < maxBytes) return 9 | resolve(ev.target.responseText.substring(0, maxBytes)) 10 | xhr.abort() 11 | } 12 | xhr.onload = (ev) => { 13 | resolve(ev.target.responseText.substring(0, maxBytes)) 14 | } 15 | xhr.onerror = (ev) => { 16 | reject(new Error(ev.target.status)) 17 | } 18 | 19 | xhr.open('GET', url) 20 | xhr.send() 21 | } catch (err) { 22 | reject(err) 23 | } 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ChatPaginationQuery.graphql: -------------------------------------------------------------------------------- 1 | query ChatPaginationQuery($bot: String!, $before: String, $last: Int! = 10) { 2 | chatOfBot(bot: $bot) { 3 | id 4 | __typename 5 | messagesConnection(before: $before, last: $last) { 6 | pageInfo { 7 | hasPreviousPage 8 | } 9 | edges { 10 | node { 11 | id 12 | __typename 13 | messageId 14 | text 15 | linkifiedText 16 | authorNickname 17 | state 18 | vote 19 | voteReason 20 | creationTime 21 | suggestedReplies 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/popup/sections/SiteAdapters.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | SiteAdapters.propTypes = { 4 | config: PropTypes.object.isRequired, 5 | updateConfig: PropTypes.func.isRequired, 6 | } 7 | 8 | export function SiteAdapters({ config, updateConfig }) { 9 | return ( 10 | <> 11 | {config.siteAdapters.map((key) => ( 12 | 25 | ))} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/background/commands.mjs: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { config as menuConfig } from '../content-script/menu-tools/index.mjs' 3 | 4 | export function registerCommands() { 5 | Browser.commands.onCommand.addListener(async (command, tab) => { 6 | const message = { 7 | itemId: command, 8 | selectionText: '', 9 | useMenuPosition: false, 10 | } 11 | console.debug('command triggered', message) 12 | 13 | if (command in menuConfig) { 14 | if (menuConfig[command].action) { 15 | menuConfig[command].action(true, tab) 16 | } 17 | 18 | if (menuConfig[command].genPrompt) { 19 | const currentTab = (await Browser.tabs.query({ active: true, currentWindow: true }))[0] 20 | Browser.tabs.sendMessage(currentTab.id, { 21 | type: 'CREATE_CHAT', 22 | data: message, 23 | }) 24 | } 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/baidu/index.mjs: -------------------------------------------------------------------------------- 1 | import { config } from '../index' 2 | 3 | export default { 4 | init: async (hostname, userConfig, getInput, mountComponent) => { 5 | try { 6 | const targetNode = document.getElementById('wrapper_wrapper') 7 | const observer = new MutationObserver(async (records) => { 8 | if ( 9 | records.some( 10 | (record) => 11 | record.type === 'childList' && 12 | [...record.addedNodes].some((node) => node.id === 'container'), 13 | ) 14 | ) { 15 | const searchValue = await getInput(config.baidu.inputQuery) 16 | if (searchValue) { 17 | mountComponent('baidu', config.baidu) 18 | } 19 | } 20 | }) 21 | observer.observe(targetNode, { childList: true }) 22 | } catch (e) { 23 | /* empty */ 24 | } 25 | return true 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report---问题报告.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report / 问题报告 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | **问题描述** 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | **如何复现** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | **期望行为** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | **截图说明** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Please complete the following information):** 31 | **请补全以下内容** 32 | - OS: [e.g. Windows] 33 | - Browser: [e.g. chrome, safari] 34 | - Extension Version: [e.g. v2.0.2] 35 | 36 | **Additional context** 37 | **其他** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/utils/index.mjs: -------------------------------------------------------------------------------- 1 | export * from './change-children-font-size' 2 | export * from './create-element-at-position' 3 | export * from './crop-text' 4 | export * from './ends-with-question-mark' 5 | export * from './fetch-sse' 6 | export * from './get-client-position' 7 | export * from './get-conversation-pairs' 8 | export * from './get-core-content-text' 9 | export * from './get-possible-element-by-query-selector' 10 | export * from './is-edge' 11 | export * from './is-firefox' 12 | export * from './is-mobile' 13 | export * from './is-safari' 14 | export * from './limited-fetch' 15 | export * from './open-url' 16 | export * from './parse-float-with-clamp' 17 | export * from './parse-int-with-clamp' 18 | export * from './set-element-position-in-viewport' 19 | export * from './eventsource-parser.mjs' 20 | export * from './update-ref-height' 21 | export * from './wait-for-element-to-exist-and-select.mjs' 22 | export * from './model-name-convert.mjs' 23 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/quora/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | if (location.pathname === '/') return 7 | 8 | const texts = document.querySelectorAll('.q-box.qu-userSelect--text') 9 | let title 10 | if (texts.length > 0) title = texts[0].textContent 11 | let answers = '' 12 | if (texts.length > 1) 13 | for (let i = 1; i < texts.length; i++) { 14 | answers += `answer${i}:${texts[i].textContent}|` 15 | } 16 | 17 | return await cropText( 18 | `You are an insightful analyst of Q&A discussions. ` + 19 | `Below is content from a Q&A platform. Please provide a summary of the discussion and your opinion on it.\n` + 20 | `Question: '${title}'\n` + 21 | `Answers:\n${answers}`, 22 | ) 23 | } catch (e) { 24 | console.log(e) 25 | } 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/fetch-bg.mjs: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | 3 | /** 4 | * @param {RequestInfo|URL} input 5 | * @param {RequestInit=} init 6 | * @returns {Promise} 7 | */ 8 | export function fetchBg(input, init) { 9 | return new Promise((resolve, reject) => { 10 | Browser.runtime 11 | .sendMessage({ 12 | type: 'FETCH', 13 | data: { input, init }, 14 | }) 15 | .then((messageResponse) => { 16 | const [response, error] = messageResponse 17 | if (response === null) { 18 | reject(error) 19 | } else { 20 | const body = response.body ? new Blob([response.body]) : undefined 21 | resolve( 22 | new Response(body, { 23 | status: response.status, 24 | statusText: response.statusText, 25 | headers: new Headers(response.headers), 26 | }), 27 | ) 28 | } 29 | }) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/reddit/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('[id*="post-title"]')?.textContent 7 | const description = document.querySelector( 8 | 'shreddit-post > div.text-neutral-content', 9 | )?.textContent 10 | const texts = document.querySelectorAll('shreddit-comment div.md') 11 | let answers = '' 12 | for (let i = 0; i < texts.length; i++) { 13 | answers += `answer${i}:${texts[i].textContent}|` 14 | } 15 | 16 | return await cropText( 17 | `You are an expert in analyzing online forum discussions. ` + 18 | `Below is content from a social forum (Reddit). Please provide a summary of the discussion and your opinion on it.\n` + 19 | `Title: '${title}'\n` + 20 | `Description: '${description}'\n` + 21 | `Comments:\n${answers}`, 22 | ) 23 | } catch (e) { 24 | console.log(e) 25 | } 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/use-config.mjs: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { defaultConfig, getUserConfig } from '../config/index.mjs' 3 | import Browser from 'webextension-polyfill' 4 | 5 | export function useConfig(initFn, ignoreSession = true) { 6 | const [config, setConfig] = useState(defaultConfig) 7 | useEffect(() => { 8 | getUserConfig().then((config) => { 9 | setConfig(config) 10 | if (initFn) initFn() 11 | }) 12 | }, []) 13 | useEffect(() => { 14 | const listener = (changes) => { 15 | if (ignoreSession) if (Object.keys(changes).length === 1 && 'sessions' in changes) return 16 | 17 | const changedItems = Object.keys(changes) 18 | let newConfig = {} 19 | for (const key of changedItems) { 20 | newConfig[key] = changes[key].newValue 21 | } 22 | setConfig({ ...config, ...newConfig }) 23 | } 24 | Browser.storage.local.onChanged.addListener(listener) 25 | return () => { 26 | Browser.storage.local.onChanged.removeListener(listener) 27 | } 28 | }, [config]) 29 | return config 30 | } 31 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/arxiv/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('.title')?.textContent.trim() 7 | const authors = document.querySelector('.authors')?.textContent 8 | const abstract = document.querySelector('blockquote.abstract')?.textContent.trim() 9 | 10 | return await cropText( 11 | `You are a research assistant skilled in academic paper analysis. ` + 12 | `Based on the provided paper abstract from a preprint site, generate a structured summary. ` + 13 | `The summary should clearly outline: key findings, methodology, and conclusions. ` + 14 | `Pay special attention to highlighting the main contributions of the paper. ` + 15 | `Ensure the summary is concise and maintains an academic tone.\n` + 16 | `Title: ${title}\n` + 17 | `Authors: ${authors}\n` + 18 | `Abstract: ${abstract}`, 19 | ) 20 | } catch (e) { 21 | console.log(e) 22 | } 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /src/components/CopyButton/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { CheckIcon, CopyIcon } from '@primer/octicons-react' 3 | import PropTypes from 'prop-types' 4 | import { useTranslation } from 'react-i18next' 5 | 6 | CopyButton.propTypes = { 7 | contentFn: PropTypes.func.isRequired, 8 | size: PropTypes.number.isRequired, 9 | className: PropTypes.string, 10 | } 11 | 12 | function CopyButton({ className, contentFn, size }) { 13 | const { t } = useTranslation() 14 | const [copied, setCopied] = useState(false) 15 | 16 | const onClick = () => { 17 | navigator.clipboard 18 | .writeText(contentFn()) 19 | .then(() => setCopied(true)) 20 | .then(() => 21 | setTimeout(() => { 22 | setCopied(false) 23 | }, 600), 24 | ) 25 | } 26 | 27 | return ( 28 | 33 | {copied ? : } 34 | 35 | ) 36 | } 37 | 38 | export default CopyButton 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 josStorer 4 | Copyright (c) 2022 wong2 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/utils/jwt-token-generator.mjs: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | let jwtToken = null 4 | let tokenExpiration = null // Declare tokenExpiration in the module scope 5 | 6 | function generateToken(apiKey, timeoutSeconds) { 7 | const parts = apiKey.split('.') 8 | if (parts.length !== 2) { 9 | throw new Error('Invalid API key') 10 | } 11 | 12 | const ms = Date.now() 13 | const currentSeconds = Math.floor(ms / 1000) 14 | const [id, secret] = parts 15 | const payload = { 16 | api_key: id, 17 | exp: currentSeconds + timeoutSeconds, 18 | timestamp: currentSeconds, 19 | } 20 | 21 | jwtToken = jwt.sign(payload, secret, { 22 | header: { 23 | alg: 'HS256', 24 | typ: 'JWT', 25 | sign_type: 'SIGN', 26 | }, 27 | }) 28 | tokenExpiration = ms + timeoutSeconds * 1000 29 | } 30 | 31 | function shouldRegenerateToken() { 32 | const ms = Date.now() 33 | return !jwtToken || ms >= tokenExpiration 34 | } 35 | 36 | function getToken(apiKey) { 37 | if (shouldRegenerateToken()) { 38 | generateToken(apiKey, 86400) // Hard-coded to regenerate the token every 24 hours 39 | } 40 | return jwtToken 41 | } 42 | 43 | export { getToken } 44 | -------------------------------------------------------------------------------- /src/_locales/resources.mjs: -------------------------------------------------------------------------------- 1 | import de from './de/main.json' 2 | import en from './en/main.json' 3 | import es from './es/main.json' 4 | import fr from './fr/main.json' 5 | import inTrans from './in/main.json' 6 | import it from './it/main.json' 7 | import ja from './ja/main.json' 8 | import ko from './ko/main.json' 9 | import pt from './pt/main.json' 10 | import ru from './ru/main.json' 11 | import tr from './tr/main.json' 12 | import zhHans from './zh-hans/main.json' 13 | import zhHant from './zh-hant/main.json' 14 | 15 | export const resources = { 16 | de: { 17 | translation: de, 18 | }, 19 | en: { 20 | translation: en, 21 | }, 22 | es: { 23 | translation: es, 24 | }, 25 | fr: { 26 | translation: fr, 27 | }, 28 | in: { 29 | translation: inTrans, 30 | }, 31 | it: { 32 | translation: it, 33 | }, 34 | ja: { 35 | translation: ja, 36 | }, 37 | ko: { 38 | translation: ko, 39 | }, 40 | pt: { 41 | translation: pt, 42 | }, 43 | ru: { 44 | translation: ru, 45 | }, 46 | tr: { 47 | translation: tr, 48 | }, 49 | zh: { 50 | translation: zhHans, 51 | }, 52 | zhHant: { 53 | translation: zhHant, 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /src/services/apis/bard-web.mjs: -------------------------------------------------------------------------------- 1 | import { pushRecord } from './shared.mjs' 2 | import Bard from '../clients/bard' 3 | 4 | /** 5 | * @param {Runtime.Port} port 6 | * @param {string} question 7 | * @param {Session} session 8 | * @param {string} cookies 9 | */ 10 | export async function generateAnswersWithBardWebApi(port, question, session, cookies) { 11 | // const { controller, messageListener, disconnectListener } = setAbortController(port) 12 | const bot = new Bard(cookies) 13 | 14 | // eslint-disable-next-line 15 | try { 16 | const { answer, conversationObj } = await bot.ask(question, session.bard_conversationObj || {}) 17 | session.bard_conversationObj = conversationObj 18 | pushRecord(session, question, answer) 19 | console.debug('conversation history', { content: session.conversationRecords }) 20 | // port.onMessage.removeListener(messageListener) 21 | // port.onDisconnect.removeListener(disconnectListener) 22 | port.postMessage({ answer: answer, done: true, session: session }) 23 | } catch (err) { 24 | // port.onMessage.removeListener(messageListener) 25 | // port.onDisconnect.removeListener(disconnectListener) 26 | throw err 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/ViewerStateFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment ViewerStateFragment on Viewer { 2 | id 3 | __typename 4 | iosMinSupportedVersion: integerGate(gateName: "poe_ios_min_supported_version") 5 | iosMinEncouragedVersion: integerGate( 6 | gateName: "poe_ios_min_encouraged_version" 7 | ) 8 | macosMinSupportedVersion: integerGate( 9 | gateName: "poe_macos_min_supported_version" 10 | ) 11 | macosMinEncouragedVersion: integerGate( 12 | gateName: "poe_macos_min_encouraged_version" 13 | ) 14 | showPoeDebugPanel: booleanGate(gateName: "poe_show_debug_panel") 15 | enableCommunityFeed: booleanGate(gateName: "enable_poe_shares_feed") 16 | linkifyText: booleanGate(gateName: "poe_linkify_response") 17 | enableSuggestedReplies: booleanGate(gateName: "poe_suggested_replies") 18 | removeInviteLimit: booleanGate(gateName: "poe_remove_invite_limit") 19 | enableInAppPurchases: booleanGate(gateName: "poe_enable_in_app_purchases") 20 | availableBots { 21 | nickname 22 | displayName 23 | profilePicture 24 | isDown 25 | disclaimer 26 | subtitle 27 | poweredBy 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/components/MarkdownRender/Hyperlink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import Browser from 'webextension-polyfill' 3 | 4 | export function Hyperlink({ href, children }) { 5 | const linkProperties = { 6 | target: '_blank', 7 | style: 'color: #8ab4f8; cursor: pointer;', 8 | rel: 'nofollow noopener noreferrer', 9 | } 10 | 11 | return href.includes('chatgpt.com') || 12 | href.includes('claude.ai') || 13 | href.includes('kimi.moonshot.cn') || 14 | href.includes('kimi.com') ? ( 15 | { 18 | const url = new URL(href) 19 | url.searchParams.set('chatgptbox_notification', 'true') 20 | Browser.runtime.sendMessage({ 21 | type: 'NEW_URL', 22 | data: { 23 | url: url.toString(), 24 | pinned: false, 25 | jumpBack: true, 26 | }, 27 | }) 28 | }} 29 | > 30 | {children} 31 | 32 | ) : ( 33 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | Hyperlink.propTypes = { 40 | href: PropTypes.string.isRequired, 41 | children: PropTypes.object.isRequired, 42 | } 43 | -------------------------------------------------------------------------------- /src/services/apis/ollama-api.mjs: -------------------------------------------------------------------------------- 1 | import { getUserConfig } from '../../config/index.mjs' 2 | import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' 3 | import { getModelValue } from '../../utils/model-name-convert.mjs' 4 | 5 | /** 6 | * @param {Browser.Runtime.Port} port 7 | * @param {string} question 8 | * @param {Session} session 9 | */ 10 | export async function generateAnswersWithOllamaApi(port, question, session) { 11 | const config = await getUserConfig() 12 | const model = getModelValue(session) 13 | return generateAnswersWithChatgptApiCompat( 14 | config.ollamaEndpoint + '/v1', 15 | port, 16 | question, 17 | session, 18 | config.ollamaApiKey, 19 | ).then(() => 20 | fetch(config.ollamaEndpoint + '/api/generate', { 21 | method: 'POST', 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | Authorization: `Bearer ${config.ollamaApiKey}`, 25 | }, 26 | body: JSON.stringify({ 27 | model, 28 | prompt: 't', 29 | options: { 30 | num_predict: 1, 31 | }, 32 | keep_alive: config.ollamaKeepAliveTime === '-1' ? -1 : config.ollamaKeepAliveTime, 33 | }), 34 | }), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/config/language.mjs: -------------------------------------------------------------------------------- 1 | import { languages } from 'countries-list' 2 | import { defaultConfig, getUserConfig } from './index.mjs' 3 | 4 | export const languageList = { auto: { name: 'Auto', native: 'Auto' }, ...languages } 5 | languageList.zh.name = 'Chinese (Simplified)' 6 | languageList.zh.native = '简体中文' 7 | languageList.zhHant = { ...languageList.zh } 8 | languageList.zhHant.name = 'Chinese (Traditional)' 9 | languageList.zhHant.native = '正體中文' 10 | languageList.in = {} 11 | languageList.in.name = 'Indonesia' 12 | languageList.in.native = 'Indonesia' 13 | 14 | export async function getUserLanguage() { 15 | return languageList[defaultConfig.userLanguage].name 16 | } 17 | 18 | export async function getUserLanguageNative() { 19 | return languageList[defaultConfig.userLanguage].native 20 | } 21 | 22 | export async function getPreferredLanguage() { 23 | const config = await getUserConfig() 24 | if (config.preferredLanguage === 'auto') return await getUserLanguage() 25 | return languageList[config.preferredLanguage].name 26 | } 27 | 28 | export async function getPreferredLanguageNative() { 29 | const config = await getUserConfig() 30 | if (config.preferredLanguage === 'auto') return await getUserLanguageNative() 31 | return languageList[config.preferredLanguage].native 32 | } 33 | -------------------------------------------------------------------------------- /src/popup/index.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact' 2 | import Popup from './Popup' 3 | import '../_locales/i18n-react' 4 | import { getUserConfig } from '../config/index.mjs' 5 | import { config as menuConfig } from '../content-script/menu-tools/index.mjs' 6 | import Browser from 'webextension-polyfill' 7 | 8 | getUserConfig().then(async (config) => { 9 | if (config.clickIconAction === 'popup' || (window.innerWidth > 100 && window.innerHeight > 100)) { 10 | render(, document.getElementById('app')) 11 | } else { 12 | const message = { 13 | itemId: config.clickIconAction, 14 | selectionText: '', 15 | useMenuPosition: false, 16 | } 17 | console.debug('custom icon action triggered', message) 18 | 19 | if (config.clickIconAction in menuConfig) { 20 | const currentTab = (await Browser.tabs.query({ active: true, currentWindow: true }))[0] 21 | 22 | if (menuConfig[config.clickIconAction].action) { 23 | menuConfig[config.clickIconAction].action(false, currentTab) 24 | } 25 | 26 | if (menuConfig[config.clickIconAction].genPrompt) { 27 | Browser.tabs.sendMessage(currentTab.id, { 28 | type: 'CREATE_CHAT', 29 | data: message, 30 | }) 31 | } 32 | } 33 | window.close() 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /src/popup/sections/ModulesPart.jsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import PropTypes from 'prop-types' 3 | import { Tab, TabList, TabPanel, Tabs } from 'react-tabs' 4 | import { ApiModes } from './ApiModes' 5 | import { SelectionTools } from './SelectionTools' 6 | import { SiteAdapters } from './SiteAdapters' 7 | 8 | ModulesPart.propTypes = { 9 | config: PropTypes.object.isRequired, 10 | updateConfig: PropTypes.func.isRequired, 11 | } 12 | 13 | export function ModulesPart({ config, updateConfig }) { 14 | const { t } = useTranslation() 15 | 16 | return ( 17 | <> 18 | 19 | 20 | {t('API Modes')} 21 | {t('Selection Tools')} 22 | {t('Sites')} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/juejin/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('#juejin .article-title')?.innerText 7 | const description = document.querySelector('#juejin #article-root')?.innerText 8 | if (title && description) { 9 | const author = document.querySelector('#juejin .author-block .info-box span')?.innerText 10 | const comments = document.querySelectorAll('.comment-list .comment-content') 11 | let comment = '' 12 | for (let i = 1; i <= comments.length && i <= 4; i++) { 13 | comment += `answer${i}: ${comment[i - 1].innerText}|` 14 | } 15 | return await cropText( 16 | `You are an expert content analyst and summarizer. ` + 17 | `Please analyze the following Juejin article and its comments. Provide a summary of the article (including author), your opinion on it, and a summary of the comments.\n` + 18 | `Article Title: "${title}"\n` + 19 | `Author: "${author}"\n` + 20 | `Content:\n"${description}"\n\n` + 21 | `Selected comments:\n${comment}`, 22 | ) 23 | } 24 | } catch (e) { 25 | console.log(e) 26 | } 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/fetch-sse.mjs: -------------------------------------------------------------------------------- 1 | import { createParser } from './eventsource-parser.mjs' 2 | 3 | export async function fetchSSE(resource, options) { 4 | const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options 5 | const resp = await fetch(resource, fetchOptions).catch(async (err) => { 6 | await onError(err) 7 | }) 8 | if (!resp) return 9 | if (!resp.ok) { 10 | await onError(resp) 11 | return 12 | } 13 | const parser = createParser((event) => { 14 | if (event.type === 'event') { 15 | onMessage(event.data) 16 | } 17 | }) 18 | let hasStarted = false 19 | const reader = resp.body.getReader() 20 | let result 21 | while (!(result = await reader.read()).done) { 22 | const chunk = result.value 23 | if (!hasStarted) { 24 | const str = new TextDecoder().decode(chunk) 25 | hasStarted = true 26 | await onStart(str) 27 | 28 | let fakeSseData 29 | try { 30 | const commonResponse = JSON.parse(str) 31 | fakeSseData = 'data: ' + JSON.stringify(commonResponse) + '\n\ndata: [DONE]\n\n' 32 | } catch (error) { 33 | console.debug('not common response', error) 34 | } 35 | if (fakeSseData) { 36 | parser.feed(new TextEncoder().encode(fakeSseData)) 37 | break 38 | } 39 | } 40 | parser.feed(chunk) 41 | } 42 | await onEnd() 43 | } 44 | -------------------------------------------------------------------------------- /src/services/clients/poe/graphql/AddHumanMessageMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation AddHumanMessageMutation( 2 | $chatId: BigInt! 3 | $bot: String! 4 | $query: String! 5 | $source: MessageSource 6 | $withChatBreak: Boolean! = false 7 | ) { 8 | messageCreateWithStatus( 9 | chatId: $chatId 10 | bot: $bot 11 | query: $query 12 | source: $source 13 | withChatBreak: $withChatBreak 14 | ) { 15 | message { 16 | id 17 | __typename 18 | messageId 19 | text 20 | linkifiedText 21 | authorNickname 22 | state 23 | vote 24 | voteReason 25 | creationTime 26 | suggestedReplies 27 | chat { 28 | id 29 | shouldShowDisclaimer 30 | } 31 | } 32 | messageLimit{ 33 | canSend 34 | numMessagesRemaining 35 | resetTime 36 | shouldShowReminder 37 | } 38 | chatBreak { 39 | id 40 | __typename 41 | messageId 42 | text 43 | linkifiedText 44 | authorNickname 45 | state 46 | vote 47 | voteReason 48 | creationTime 49 | suggestedReplies 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/weixin/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('#activity-name')?.textContent 7 | const description = document.querySelector('#js_content')?.textContent 8 | if (title && description) { 9 | const author = document.querySelector('#js_name')?.textContent 10 | 11 | const sidebar = document.querySelector('.qr_code_pc') 12 | if (sidebar) { 13 | sidebar.style.right = '-400px' 14 | sidebar.style.width = '400px' 15 | sidebar.style.textAlign = 'left' 16 | sidebar.style.alignItems = 'center' 17 | sidebar.style.display = 'flex' 18 | sidebar.style.flexDirection = 'column' 19 | sidebar.style.background = 'transparent' 20 | } 21 | 22 | return await cropText( 23 | `You are an expert article analyst and summarizer. ` + 24 | `Please analyze the following WeChat Official Account article. Provide the source, a summary of the article, its main conclusions, and your opinion on it.\n` + 25 | `Article Title: "${title}"\n` + 26 | `Source: "${author} Official Account"\n` + 27 | `Content:\n"${description}"`, 28 | ) 29 | } 30 | } catch (e) { 31 | console.log(e) 32 | } 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /src/components/MarkdownRender/Pre.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import CopyButton from '../CopyButton' 3 | import PropTypes from 'prop-types' 4 | import { changeChildrenFontSize } from '../../utils' 5 | 6 | export function Pre({ className, children }) { 7 | const preRef = useRef(null) 8 | const [fontSize, setFontSize] = useState(14) 9 | const sizeList = [10, 12, 14, 16, 18] 10 | 11 | useEffect(() => { 12 | changeChildrenFontSize(preRef.current.childNodes[1], fontSize + 'px') 13 | }) 14 | 15 | return ( 16 |
17 |       
18 |         
33 |          preRef.current.childNodes[1].textContent} size={14} />
34 |       
35 |       {children}
36 |     
37 | ) 38 | } 39 | 40 | Pre.propTypes = { 41 | className: PropTypes.string.isRequired, 42 | children: PropTypes.object.isRequired, 43 | } 44 | -------------------------------------------------------------------------------- /src/components/ConfirmButton/index.jsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { useEffect, useRef, useState } from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | ConfirmButton.propTypes = { 6 | onConfirm: PropTypes.func.isRequired, 7 | text: PropTypes.string.isRequired, 8 | } 9 | 10 | function ConfirmButton({ onConfirm, text }) { 11 | const { t } = useTranslation() 12 | const [waitConfirm, setWaitConfirm] = useState(false) 13 | const confirmRef = useRef(null) 14 | 15 | useEffect(() => { 16 | if (waitConfirm) confirmRef.current.focus() 17 | }, [waitConfirm]) 18 | 19 | return ( 20 | 21 | 42 | 54 | 55 | ) 56 | } 57 | 58 | export default ConfirmButton 59 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/followin/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const author = document.querySelector('main article a > span')?.textContent 7 | const description = 8 | document.querySelector('#article-content')?.textContent || 9 | document.querySelector('#thead-gallery')?.textContent 10 | if (author && description) { 11 | const title = document.querySelector('main article h1')?.textContent 12 | if (title) { 13 | return await cropText( 14 | `You are an expert content summarizer. Please carefully read the following article. ` + 15 | `Provide a conclusion and 3 to 5 main points, presented as a markdown list. ` + 16 | `The summary should be concise, clear, and accurately reflect the core content.\n` + 17 | `Title: "${title}"\n` + 18 | `Author: "${author}"\n` + 19 | `Content:\n"${description}"`, 20 | ) 21 | } else { 22 | return await cropText( 23 | `You are an expert content summarizer. Please carefully read the following long tweet. ` + 24 | `Provide a conclusion and 3 to 5 main points, presented as a markdown list. ` + 25 | `The summary should be concise, clear, and accurately reflect the core content.\n` + 26 | `Author: "${author}"\n` + 27 | `Content:\n"${description}"`, 28 | ) 29 | } 30 | } 31 | } catch (e) { 32 | console.log(e) 33 | } 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/services/apis/poe-web.mjs: -------------------------------------------------------------------------------- 1 | import { pushRecord, setAbortController } from './shared.mjs' 2 | import PoeAiClient from '../clients/poe/index.mjs' 3 | 4 | /** 5 | * @param {Runtime.Port} port 6 | * @param {string} question 7 | * @param {Session} session 8 | * @param {string} modelName 9 | */ 10 | export async function generateAnswersWithPoeWebApi(port, question, session, modelName) { 11 | const bot = new PoeAiClient(session.poe_chatId) 12 | const { messageListener, disconnectListener } = setAbortController( 13 | port, 14 | () => { 15 | bot.close() 16 | }, 17 | () => { 18 | bot.breakMsg() 19 | bot.close() 20 | }, 21 | ) 22 | 23 | let answer = '' 24 | await bot 25 | .ask( 26 | question, 27 | modelName, 28 | (msg) => { 29 | answer += msg 30 | port.postMessage({ answer: answer, done: false, session: null }) 31 | }, 32 | () => { 33 | if (bot.chatId) session.poe_chatId = bot.chatId 34 | 35 | pushRecord(session, question, answer) 36 | console.debug('conversation history', { content: session.conversationRecords }) 37 | port.onMessage.removeListener(messageListener) 38 | if (session.conversationRecords.length > 1) 39 | port.onDisconnect.removeListener(disconnectListener) 40 | port.postMessage({ answer: answer, done: true, session: session }) 41 | bot.close() 42 | }, 43 | ) 44 | .catch((err) => { 45 | port.onMessage.removeListener(messageListener) 46 | port.onDisconnect.removeListener(disconnectListener) 47 | bot.close() 48 | throw err 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/stackoverflow/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('#question-header .question-hyperlink')?.textContent 7 | if (title) { 8 | const description = document.querySelector('.postcell .s-prose')?.textContent 9 | let answer = '' 10 | const answers = document.querySelectorAll('.answercell .s-prose') 11 | if (answers.length > 0) 12 | for (let i = 1; i <= answers.length && i <= 2; i++) { 13 | answer += `answer${i}: ${answers[i - 1].textContent}|` 14 | } 15 | 16 | return await cropText( 17 | `You are an expert software developer and technical problem solver. ` + 18 | `The following content is from a developer Q&A platform (Stack Overflow).\n\n` + 19 | `Question: "${title}"\n` + 20 | `Question Description: "${description}"\n\n` + 21 | `Provided Answers:\n${answer}\n\n` + 22 | `Please perform the following tasks:\n` + 23 | `1. **Direct Solution:** Based on the provided answers, formulate a concise and effective solution to the question. ` + 24 | `If applicable, include a brief code snippet (using markdown for formatting).\n` + 25 | `2. **Overview of Answers:** Provide an overview of the different approaches or key points mentioned in the provided answers. ` + 26 | `You can highlight any notable variations, pros, or cons if apparent.`, 27 | ) 28 | } 29 | } catch (e) { 30 | console.log(e) 31 | } 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /src/components/DeleteButton/index.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useTranslation } from 'react-i18next' 4 | import { TrashIcon } from '@primer/octicons-react' 5 | 6 | DeleteButton.propTypes = { 7 | onConfirm: PropTypes.func.isRequired, 8 | size: PropTypes.number.isRequired, 9 | text: PropTypes.string.isRequired, 10 | } 11 | 12 | function DeleteButton({ onConfirm, size, text }) { 13 | const { t } = useTranslation() 14 | const [waitConfirm, setWaitConfirm] = useState(false) 15 | const confirmRef = useRef(null) 16 | 17 | useEffect(() => { 18 | if (waitConfirm) confirmRef.current.focus() 19 | }, [waitConfirm]) 20 | 21 | return ( 22 | 23 | 45 | { 50 | setWaitConfirm(true) 51 | }} 52 | > 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default DeleteButton 60 | -------------------------------------------------------------------------------- /src/rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "action": { 5 | "type": "modifyHeaders", 6 | "requestHeaders": [ 7 | { 8 | "operation": "set", 9 | "header": "origin", 10 | "value": "https://www.bing.com" 11 | }, 12 | { 13 | "operation": "set", 14 | "header": "referer", 15 | "value": "https://www.bing.com/search?q=Bing+AI&showconv=1&FORM=hpcodx" 16 | } 17 | ] 18 | }, 19 | "condition": { 20 | "requestDomains": ["sydney.bing.com", "www.bing.com"], 21 | "resourceTypes": ["xmlhttprequest", "websocket"] 22 | } 23 | }, 24 | { 25 | "id": 2, 26 | "action": { 27 | "type": "modifyHeaders", 28 | "requestHeaders": [ 29 | { 30 | "operation": "set", 31 | "header": "origin", 32 | "value": "https://chatgpt.com" 33 | }, 34 | { 35 | "operation": "set", 36 | "header": "referer", 37 | "value": "https://chatgpt.com" 38 | } 39 | ] 40 | }, 41 | "condition": { 42 | "requestDomains": ["chatgpt.com"], 43 | "resourceTypes": ["xmlhttprequest"] 44 | } 45 | }, 46 | { 47 | "id": 3, 48 | "action": { 49 | "type": "modifyHeaders", 50 | "requestHeaders": [ 51 | { 52 | "operation": "set", 53 | "header": "origin", 54 | "value": "https://claude.ai" 55 | }, 56 | { 57 | "operation": "set", 58 | "header": "referer", 59 | "value": "https://claude.ai" 60 | } 61 | ] 62 | }, 63 | "condition": { 64 | "requestDomains": ["claude.ai"], 65 | "resourceTypes": ["xmlhttprequest"] 66 | } 67 | } 68 | ] 69 | -------------------------------------------------------------------------------- /src/services/apis/claude-web.mjs: -------------------------------------------------------------------------------- 1 | import { pushRecord, setAbortController } from './shared.mjs' 2 | import Claude from '../clients/claude' 3 | import { getModelValue } from '../../utils/model-name-convert.mjs' 4 | 5 | /** 6 | * @param {Runtime.Port} port 7 | * @param {string} question 8 | * @param {Session} session 9 | * @param {string} sessionKey 10 | */ 11 | export async function generateAnswersWithClaudeWebApi(port, question, session, sessionKey) { 12 | const bot = new Claude({ sessionKey }) 13 | await bot.init() 14 | const { controller, cleanController } = setAbortController(port) 15 | const model = getModelValue(session) 16 | 17 | let answer = '' 18 | const progressFunc = ({ completion }) => { 19 | answer = completion 20 | port.postMessage({ answer: answer, done: false, session: null }) 21 | } 22 | 23 | const doneFunc = () => { 24 | pushRecord(session, question, answer) 25 | console.debug('conversation history', { content: session.conversationRecords }) 26 | port.postMessage({ answer: answer, done: true, session: session }) 27 | } 28 | 29 | const params = { 30 | progress: progressFunc, 31 | done: doneFunc, 32 | model, 33 | signal: controller.signal, 34 | } 35 | 36 | if (!session.claude_conversation) 37 | await bot 38 | .startConversation(question, params) 39 | .then((conversation) => { 40 | conversation.request = null 41 | conversation.claude = null 42 | session.claude_conversation = conversation 43 | port.postMessage({ answer: answer, done: true, session: session }) 44 | cleanController() 45 | }) 46 | .catch((err) => { 47 | cleanController() 48 | throw err 49 | }) 50 | else 51 | await bot 52 | .sendMessage(question, { 53 | conversation: session.claude_conversation, 54 | ...params, 55 | }) 56 | .then(cleanController) 57 | .catch((err) => { 58 | cleanController() 59 | throw err 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/popup/styles.scss: -------------------------------------------------------------------------------- 1 | [data-theme='auto'] { 2 | @media screen and (prefers-color-scheme: dark) { 3 | --font-color: #c9d1d9; 4 | --theme-color: #202124; 5 | --active-color: #3c4043; 6 | } 7 | @media screen and (prefers-color-scheme: light) { 8 | --font-color: #24292f; 9 | --theme-color: #ffffff; 10 | --active-color: #eaecf0; 11 | } 12 | } 13 | 14 | [data-theme='dark'] { 15 | --font-color: #c9d1d9; 16 | --theme-color: #202124; 17 | --active-color: #3c4043; 18 | } 19 | 20 | [data-theme='light'] { 21 | --font-color: #24292f; 22 | --theme-color: #ffffff; 23 | --active-color: #eaecf0; 24 | } 25 | 26 | .container-page-mode { 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | min-width: 460px; 31 | min-height: 560px; 32 | width: 100%; 33 | height: 100%; 34 | padding: 20px; 35 | overflow-y: auto; 36 | } 37 | 38 | .container-popup-mode { 39 | display: flex; 40 | flex-direction: column; 41 | align-items: center; 42 | width: 460px; 43 | height: 560px; 44 | padding: 20px; 45 | overflow-y: auto; 46 | } 47 | 48 | .container legend { 49 | font-weight: bold; 50 | } 51 | 52 | .container form { 53 | margin-bottom: 0; 54 | } 55 | 56 | .container fieldset { 57 | margin-bottom: 0; 58 | } 59 | 60 | .footer { 61 | width: 90%; 62 | position: fixed; 63 | bottom: 10px; 64 | display: flex; 65 | flex-direction: row; 66 | justify-content: space-between; 67 | align-items: center; 68 | background-color: var(--active-color); 69 | border-radius: 5px; 70 | padding: 6px; 71 | z-index: 2147483647; 72 | font-size: 12px; 73 | } 74 | 75 | .popup-tab { 76 | display: inline-block; 77 | position: relative; 78 | list-style: none; 79 | padding: 6px 12px 0; 80 | cursor: pointer; 81 | border-radius: 5px; 82 | margin-right: 5px; 83 | font-size: 14px; 84 | background-color: var(--theme-color); 85 | color: var(--font-color); 86 | 87 | &--selected { 88 | background: var(--active-color); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/pre-release-build.yml: -------------------------------------------------------------------------------- 1 | name: pre-release 2 | on: 3 | workflow_dispatch: 4 | # push: 5 | # branches: 6 | # - master 7 | # paths: 8 | # - "src/**" 9 | # - "!src/**/*.json" 10 | # - "build.mjs" 11 | # tags-ignore: 12 | # - "v*" 13 | 14 | permissions: 15 | id-token: "write" 16 | contents: "write" 17 | 18 | jobs: 19 | build_and_release: 20 | runs-on: ubuntu-22.04 21 | 22 | steps: 23 | - uses: actions/checkout@v5 24 | - uses: actions/setup-node@v5 25 | with: 26 | node-version: 20 27 | - run: npm ci 28 | - run: npm run build 29 | 30 | - uses: josStorer/get-current-time@v2 31 | id: current-time 32 | with: 33 | format: YYYY_MMDD_HHmm 34 | 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: Chromium_Build_${{ steps.current-time.outputs.formattedTime }} 38 | path: build/chromium/* 39 | 40 | - uses: actions/upload-artifact@v4 41 | with: 42 | name: Firefox_Build_${{ steps.current-time.outputs.formattedTime }} 43 | path: build/firefox/* 44 | 45 | - uses: actions/upload-artifact@v4 46 | with: 47 | name: Chromium_Build_WithoutKatex_${{ steps.current-time.outputs.formattedTime }} 48 | path: build/chromium-without-katex-and-tiktoken/* 49 | 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: Firefox_Build_WithoutKatex_${{ steps.current-time.outputs.formattedTime }} 53 | path: build/firefox-without-katex-and-tiktoken/* 54 | 55 | - uses: marvinpinto/action-automatic-releases@v1.2.1 56 | with: 57 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 58 | automatic_release_tag: "latest" 59 | prerelease: true 60 | title: "Development Build" 61 | files: | 62 | build/chromium.zip 63 | build/firefox.zip 64 | build/chromium-without-katex-and-tiktoken.zip 65 | build/firefox-without-katex-and-tiktoken.zip 66 | -------------------------------------------------------------------------------- /src/components/FeedbackForChatGPTWeb/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { memo, useCallback, useState } from 'react' 3 | import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react' 4 | import Browser from 'webextension-polyfill' 5 | import { useTranslation } from 'react-i18next' 6 | 7 | const FeedbackForChatGPTWeb = (props) => { 8 | const { t } = useTranslation() 9 | const [action, setAction] = useState(null) 10 | 11 | const clickThumbsUp = useCallback(async () => { 12 | if (action) { 13 | return 14 | } 15 | setAction('thumbsUp') 16 | await Browser.runtime.sendMessage({ 17 | type: 'FEEDBACK', 18 | data: { 19 | conversation_id: props.conversationId, 20 | message_id: props.messageId, 21 | rating: 'thumbsUp', 22 | }, 23 | }) 24 | }, [props, action]) 25 | 26 | const clickThumbsDown = useCallback(async () => { 27 | if (action) { 28 | return 29 | } 30 | setAction('thumbsDown') 31 | await Browser.runtime.sendMessage({ 32 | type: 'FEEDBACK', 33 | data: { 34 | conversation_id: props.conversationId, 35 | message_id: props.messageId, 36 | rating: 'thumbsDown', 37 | text: '', 38 | tags: [], 39 | }, 40 | }) 41 | }, [props, action]) 42 | 43 | return ( 44 |
45 | 49 | 50 | 51 | 57 | 58 | 59 |
60 | ) 61 | } 62 | 63 | FeedbackForChatGPTWeb.propTypes = { 64 | messageId: PropTypes.string.isRequired, 65 | conversationId: PropTypes.string.isRequired, 66 | } 67 | 68 | export default memo(FeedbackForChatGPTWeb) 69 | -------------------------------------------------------------------------------- /src/fonts/styles.css: -------------------------------------------------------------------------------- 1 | /* arabic */ 2 | @font-face { 3 | font-family: 'Cairo'; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscQyyS4J0.woff2) format('woff2'); 8 | unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC; 9 | } 10 | /* latin-ext */ 11 | @font-face { 12 | font-family: 'Cairo'; 13 | font-style: normal; 14 | font-weight: 400; 15 | font-display: swap; 16 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscSCyS4J0.woff2) format('woff2'); 17 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, 18 | U+2C60-2C7F, U+A720-A7FF; 19 | } 20 | /* latin */ 21 | @font-face { 22 | font-family: 'Cairo'; 23 | font-style: normal; 24 | font-weight: 400; 25 | font-display: swap; 26 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscRiyS.woff2) format('woff2'); 27 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, 28 | U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 29 | } 30 | /* arabic */ 31 | @font-face { 32 | font-family: 'Cairo'; 33 | font-style: normal; 34 | font-weight: 700; 35 | font-display: swap; 36 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscQyyS4J0.woff2) format('woff2'); 37 | unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC; 38 | } 39 | /* latin-ext */ 40 | @font-face { 41 | font-family: 'Cairo'; 42 | font-style: normal; 43 | font-weight: 700; 44 | font-display: swap; 45 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscSCyS4J0.woff2) format('woff2'); 46 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, 47 | U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Cairo'; 52 | font-style: normal; 53 | font-weight: 700; 54 | font-display: swap; 55 | src: url(SLXVc1nY6HkvangtZmpQdkhzfH5lkSscRiyS.woff2) format('woff2'); 56 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, 57 | U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 58 | } 59 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/zhihu/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | 3 | export default { 4 | inputQuery: async () => { 5 | try { 6 | const title = document.querySelector('.QuestionHeader-title')?.textContent 7 | if (title) { 8 | const description = document.querySelector('.QuestionRichText')?.textContent 9 | const answerQuery = '.AnswerItem .RichText' 10 | 11 | let answer = '' 12 | if (location.pathname.includes('answer')) { 13 | answer = document.querySelector(answerQuery)?.textContent 14 | return await cropText( 15 | `You are an insightful analyst of Q&A discussions. ` + 16 | `Below is content from Zhihu, a Q&A platform. Please provide a summary of the question and answer, and your opinion on them.\n` + 17 | `Question: "${title}"\n` + 18 | `Description: "${description}"\n` + 19 | `Answer:\n${answer}`, 20 | ) 21 | } else { 22 | const answers = document.querySelectorAll(answerQuery) 23 | for (let i = 1; i <= answers.length && i <= 4; i++) { 24 | answer += `answer${i}: ${answers[i - 1].textContent}|` 25 | } 26 | return await cropText( 27 | `You are an insightful analyst of Q&A discussions. ` + 28 | `Below is content from Zhihu, a Q&A platform. Please provide a summary of the question and answers, and your opinion on them.\n` + 29 | `Question: "${title}"\n` + 30 | `Description: "${description}"\n` + 31 | `Answers:\n${answer}`, 32 | ) 33 | } 34 | } else { 35 | const title = document.querySelector('.Post-Title')?.textContent 36 | const description = document.querySelector('.Post-RichText')?.textContent 37 | 38 | if (title) { 39 | return await cropText( 40 | `You are an expert article analyst. ` + 41 | `Below is an article from Zhihu. Please provide a summary of the article and your opinion on it.\n` + 42 | `Title: "${title}"\n` + 43 | `Content:\n"${description}"`, 44 | ) 45 | } 46 | } 47 | } catch (e) { 48 | console.log(e) 49 | } 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /src/manifest.v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChatGPTBox", 3 | "description": "Integrating ChatGPT into your browser deeply, everything you need is here", 4 | "version": "2.5.9", 5 | "manifest_version": 2, 6 | "icons": { 7 | "16": "logo.png", 8 | "32": "logo.png", 9 | "48": "logo.png", 10 | "128": "logo.png" 11 | }, 12 | "permissions": [ 13 | "cookies", 14 | "storage", 15 | "contextMenus", 16 | "unlimitedStorage", 17 | "tabs", 18 | "webRequest", 19 | "https://*.chatgpt.com/*", 20 | "https://*.openai.com/", 21 | "https://*.bing.com/", 22 | "wss://*.bing.com/*", 23 | "https://*.poe.com/", 24 | "https://*.google.com/", 25 | "https://claude.ai/", 26 | "https://*.moonshot.cn/*", 27 | "" 28 | ], 29 | "background": { 30 | "scripts": [ 31 | "background.js" 32 | ], 33 | "persistent": true 34 | }, 35 | "browser_action": { 36 | "default_popup": "popup.html?popup=true" 37 | }, 38 | "options_ui": { 39 | "page": "popup.html", 40 | "open_in_tab": true 41 | }, 42 | "content_scripts": [ 43 | { 44 | "matches": [ 45 | "https://*/*", 46 | "http://*/*", 47 | "file://*/*" 48 | ], 49 | "js": [ 50 | "shared.js", 51 | "content-script.js" 52 | ], 53 | "css": [ 54 | "content-script.css" 55 | ] 56 | } 57 | ], 58 | "web_accessible_resources": [ 59 | "logo.png" 60 | ], 61 | "commands": { 62 | "newChat": { 63 | "suggested_key": { 64 | "default": "Ctrl+B", 65 | "mac": "MacCtrl+X" 66 | }, 67 | "description": "Create a new chat" 68 | }, 69 | "summarizePage": { 70 | "suggested_key": { 71 | "default": "Alt+B", 72 | "mac": "Alt+B" 73 | }, 74 | "description": "Summarize this page" 75 | }, 76 | "openConversationPage": { 77 | "suggested_key": { 78 | "default": "Ctrl+Shift+H", 79 | "mac": "MacCtrl+Shift+H" 80 | }, 81 | "description": "Open the independent conversation page" 82 | }, 83 | "openConversationWindow": { 84 | "description": "Open the independent conversation window" 85 | }, 86 | "closeAllChats": { 87 | "description": "Close all chats in this page" 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/components/ReadButton/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { MuteIcon, UnmuteIcon } from '@primer/octicons-react' 3 | import PropTypes from 'prop-types' 4 | import { useTranslation } from 'react-i18next' 5 | import { useConfig } from '../../hooks/use-config.mjs' 6 | 7 | ReadButton.propTypes = { 8 | contentFn: PropTypes.func.isRequired, 9 | size: PropTypes.number.isRequired, 10 | className: PropTypes.string, 11 | } 12 | 13 | const synth = window.speechSynthesis 14 | 15 | function ReadButton({ className, contentFn, size }) { 16 | const { t } = useTranslation() 17 | const [speaking, setSpeaking] = useState(false) 18 | const config = useConfig() 19 | 20 | const startSpeak = () => { 21 | synth.cancel() 22 | 23 | const text = contentFn() 24 | const utterance = new SpeechSynthesisUtterance(text) 25 | const voices = synth.getVoices() 26 | 27 | let voice 28 | if (config.preferredLanguage.includes('en') && navigator.language.includes('en')) 29 | voice = voices.find((v) => v.name.toLowerCase().includes('microsoft aria')) 30 | else if (config.preferredLanguage.includes('zh') || navigator.language.includes('zh')) 31 | voice = voices.find((v) => v.name.toLowerCase().includes('xiaoyi')) 32 | else if (config.preferredLanguage.includes('ja') || navigator.language.includes('ja')) 33 | voice = voices.find((v) => v.name.toLowerCase().includes('nanami')) 34 | if (!voice) voice = voices.find((v) => v.lang.substring(0, 2) === config.preferredLanguage) 35 | if (!voice) voice = voices.find((v) => v.lang === navigator.language) 36 | 37 | Object.assign(utterance, { 38 | rate: 1, 39 | volume: 1, 40 | onend: () => setSpeaking(false), 41 | onerror: () => setSpeaking(false), 42 | voice: voice, 43 | }) 44 | 45 | synth.speak(utterance) 46 | setSpeaking(true) 47 | } 48 | 49 | const stopSpeak = () => { 50 | synth.cancel() 51 | setSpeaking(false) 52 | } 53 | 54 | return ( 55 | 60 | {speaking ? : } 61 | 62 | ) 63 | } 64 | 65 | export default ReadButton 66 | -------------------------------------------------------------------------------- /src/services/apis/shared.mjs: -------------------------------------------------------------------------------- 1 | export const getChatSystemPromptBase = async () => { 2 | return `You are a helpful, creative, clever, and very friendly assistant. You are familiar with various languages in the world.` 3 | } 4 | 5 | export const getCompletionPromptBase = async () => { 6 | return ( 7 | `The following is a conversation with an AI assistant.` + 8 | `The assistant is helpful, creative, clever, and very friendly. The assistant is familiar with various languages in the world.\n\n` + 9 | `Human: Hello, who are you?\n` + 10 | `AI: I am an AI assistant. How can I help you today?\n` 11 | ) 12 | } 13 | 14 | export const getCustomApiPromptBase = async () => { 15 | return `I am a helpful, creative, clever, and very friendly assistant. I am familiar with various languages in the world.` 16 | } 17 | 18 | export function setAbortController(port, onStop, onDisconnect) { 19 | const controller = new AbortController() 20 | const messageListener = (msg) => { 21 | if (msg.stop) { 22 | port.onMessage.removeListener(messageListener) 23 | console.debug('stop generating') 24 | port.postMessage({ done: true }) 25 | controller.abort() 26 | if (onStop) onStop() 27 | } 28 | } 29 | port.onMessage.addListener(messageListener) 30 | 31 | const disconnectListener = () => { 32 | port.onDisconnect.removeListener(disconnectListener) 33 | console.debug('port disconnected') 34 | controller.abort() 35 | if (onDisconnect) onDisconnect() 36 | } 37 | port.onDisconnect.addListener(disconnectListener) 38 | 39 | const cleanController = () => { 40 | try { 41 | port.onMessage.removeListener(messageListener) 42 | port.onDisconnect.removeListener(disconnectListener) 43 | } catch (e) { 44 | // ignore 45 | } 46 | } 47 | 48 | return { controller, cleanController, messageListener, disconnectListener } 49 | } 50 | 51 | export function pushRecord(session, question, answer) { 52 | const recordLength = session.conversationRecords.length 53 | let lastRecord 54 | if (recordLength > 0) lastRecord = session.conversationRecords[recordLength - 1] 55 | 56 | if (session.isRetry && lastRecord && lastRecord.question === question) lastRecord.answer = answer 57 | else session.conversationRecords.push({ question: question, answer: answer }) 58 | } 59 | -------------------------------------------------------------------------------- /src/services/clients/poe/websocket.js: -------------------------------------------------------------------------------- 1 | import * as diff from 'diff' 2 | 3 | const getSocketUrl = async (settings) => { 4 | settings = settings.tchannelData 5 | const tchRand = Math.floor(100000 + Math.random() * 900000) // They're surely using 6 digit random number for ws url. 6 | const socketUrl = `wss://tch${tchRand}.tch.quora.com` 7 | const boxName = settings.boxName 8 | const minSeq = settings.minSeq 9 | const channel = settings.channel 10 | const hash = settings.channelHash 11 | return `${socketUrl}/up/${boxName}/updates?min_seq=${minSeq}&channel=${channel}&hash=${hash}` 12 | } 13 | export const connectWs = async (settings) => { 14 | const url = await getSocketUrl(settings) 15 | const ws = new WebSocket(url) 16 | return new Promise((resolve) => { 17 | ws.onopen = () => { 18 | console.log('Connected to websocket') 19 | return resolve(ws) 20 | } 21 | }) 22 | } 23 | export const disconnectWs = async (ws) => { 24 | return new Promise((resolve) => { 25 | ws.onclose = () => { 26 | return resolve(true) 27 | } 28 | ws.close() 29 | }) 30 | } 31 | export const listenWs = async (ws, onMessage, onComplete) => { 32 | let previousText = '' 33 | return new Promise((resolve) => { 34 | let complete = false 35 | ws.onmessage = (e) => { 36 | let jsonData = JSON.parse(e.data) 37 | console.log(jsonData) 38 | if (jsonData.messages && jsonData.messages.length > 0) { 39 | const messages = JSON.parse(jsonData.messages[0]) 40 | const dataPayload = messages.payload.data 41 | const text = dataPayload.messageAdded.text 42 | const state = dataPayload.messageAdded.state 43 | if (state !== 'complete') { 44 | const differences = diff.diffChars(previousText, text) 45 | let result = '' 46 | differences.forEach((part) => { 47 | if (part.added) { 48 | result += part.value 49 | } 50 | }) 51 | previousText = text 52 | if (onMessage) onMessage(result) 53 | } else if (dataPayload.messageAdded.author !== 'human') { 54 | if (!complete) { 55 | complete = true 56 | if (onComplete) onComplete(text) 57 | return resolve(text) 58 | } 59 | } 60 | } 61 | } 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/is-mobile.mjs: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser 2 | 3 | export function isMobile() { 4 | if (navigator.userAgentData) return navigator.userAgentData.mobile 5 | let check = false 6 | ;(function (a) { 7 | if ( 8 | /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( 9 | a, 10 | ) || 11 | /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( 12 | a.substr(0, 4), 13 | ) 14 | ) 15 | check = true 16 | })(navigator.userAgent || navigator.vendor || window.opera) 17 | return check 18 | } 19 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/gitlab/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText, limitedFetch } from '../../../utils' 2 | 3 | const getPatchUrl = async () => { 4 | const patchUrl = location.origin + location.pathname + '.patch' 5 | const response = await fetch(patchUrl, { method: 'HEAD' }) 6 | if (response.ok) return patchUrl 7 | return '' 8 | } 9 | 10 | const getPatchData = async (patchUrl) => { 11 | if (!patchUrl) return 12 | 13 | let patchData = await limitedFetch(patchUrl, 1024 * 40) 14 | patchData = patchData.substring(patchData.indexOf('---')) 15 | return patchData 16 | } 17 | 18 | export default { 19 | inputQuery: async () => { 20 | try { 21 | if (location.pathname.includes('/blob')) { 22 | const fileData = await limitedFetch(location.href.replace('/blob/', '/raw/'), 1024 * 40) 23 | if (!fileData) return 24 | 25 | return await cropText( 26 | `You are a senior software engineer and code reviewer. ` + 27 | `Analyze the following file content thoroughly. ` + 28 | `Explain its purpose, main functionalities, and how different parts of the code contribute to its overall behavior. ` + 29 | `Identify any potential issues, areas for improvement, or notable design patterns. ` + 30 | `Use markdown syntax (e.g., code blocks, bolding, lists) to structure your explanation for better readability.\n\n` + 31 | `File content:\n\`\`\`\n${fileData}\n\`\`\``, 32 | ) 33 | } else { 34 | const patchUrl = await getPatchUrl() 35 | const patchData = await getPatchData(patchUrl) 36 | if (!patchData) return 37 | 38 | return await cropText( 39 | `You are an expert in analyzing git commits and crafting clear, concise commit messages. ` + 40 | `Based on the following git patch, please perform two tasks:\n` + 41 | `1. Generate a suitable commit message. It should follow standard conventions: a short imperative subject line (max 50 chars), ` + 42 | `followed by a blank line and a more detailed body if necessary, explaining the "what" and "why" of the changes.\n` + 43 | `2. Provide a brief summary of the changes introduced in this commit, highlighting the main purpose and impact.\n\n` + 44 | `The patch contents are as follows:\n${patchData}`, 45 | ) 46 | } 47 | } catch (e) { 48 | console.log(e) 49 | } 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | name: tagged-release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | permissions: 8 | id-token: "write" 9 | contents: "write" 10 | env: 11 | GH_TOKEN: ${{ github.token }} 12 | 13 | jobs: 14 | build_and_release: 15 | runs-on: macos-14 16 | 17 | steps: 18 | - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV 19 | - uses: actions/checkout@v5 20 | with: 21 | ref: master 22 | 23 | - name: Update manifest.json version 24 | uses: jossef/action-set-json-field@v2.2 25 | with: 26 | file: src/manifest.json 27 | field: version 28 | value: ${{ env.VERSION }} 29 | 30 | - name: Update manifest.v2.json version 31 | uses: jossef/action-set-json-field@v2.2 32 | with: 33 | file: src/manifest.v2.json 34 | field: version 35 | value: ${{ env.VERSION }} 36 | 37 | - name: Push files 38 | continue-on-error: true 39 | run: | 40 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 41 | git config --global user.name "github-actions[bot]" 42 | git commit -am "release v${{ env.VERSION }}" 43 | git push 44 | 45 | - run: | 46 | gh release create ${{github.ref_name}} -d -F CURRENT_CHANGE.md -t ${{github.ref_name}} 47 | 48 | - uses: actions/setup-node@v5 49 | with: 50 | node-version: 20 51 | - run: npm ci 52 | 53 | - uses: actions/setup-python@v6 54 | with: 55 | python-version: '3.10' # for appdmg 56 | - uses: maxim-lobanov/setup-xcode@v1 57 | with: 58 | xcode-version: 16.2 59 | - run: sed -i '' "s/0.0.0/${{ env.VERSION }}/g" safari/project.pre.patch 60 | - run: sed -i '' "s/0.0.0/${{ env.VERSION }}/g" safari/project.patch 61 | - run: npm run build:safari 62 | 63 | - run: | 64 | gh release upload ${{github.ref_name}} build/chromium.zip 65 | gh release upload ${{github.ref_name}} build/firefox.zip 66 | gh release upload ${{github.ref_name}} build/safari.dmg 67 | gh release upload ${{github.ref_name}} build/chromium-without-katex-and-tiktoken.zip 68 | gh release upload ${{github.ref_name}} build/firefox-without-katex-and-tiktoken.zip 69 | 70 | - run: | 71 | gh release edit ${{github.ref_name}} --draft=false 72 | -------------------------------------------------------------------------------- /src/background/menus.mjs: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { defaultConfig, getPreferredLanguageKey, getUserConfig } from '../config/index.mjs' 3 | import { changeLanguage, t } from 'i18next' 4 | import { config as menuConfig } from '../content-script/menu-tools/index.mjs' 5 | 6 | const menuId = 'ChatGPTBox-Menu' 7 | const onClickMenu = (info, tab) => { 8 | Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 9 | const currentTab = tabs[0] 10 | const message = { 11 | itemId: info.menuItemId.replace(menuId, ''), 12 | selectionText: info.selectionText, 13 | useMenuPosition: tab.id === currentTab.id, 14 | } 15 | console.debug('menu clicked', message) 16 | 17 | if (defaultConfig.selectionTools.includes(message.itemId)) { 18 | Browser.tabs.sendMessage(currentTab.id, { 19 | type: 'CREATE_CHAT', 20 | data: message, 21 | }) 22 | } else if (message.itemId in menuConfig) { 23 | if (menuConfig[message.itemId].action) { 24 | menuConfig[message.itemId].action(true, tab) 25 | } 26 | 27 | if (menuConfig[message.itemId].genPrompt) { 28 | Browser.tabs.sendMessage(currentTab.id, { 29 | type: 'CREATE_CHAT', 30 | data: message, 31 | }) 32 | } 33 | } 34 | }) 35 | } 36 | export function refreshMenu() { 37 | if (Browser.contextMenus.onClicked.hasListener(onClickMenu)) 38 | Browser.contextMenus.onClicked.removeListener(onClickMenu) 39 | Browser.contextMenus.removeAll().then(async () => { 40 | if ((await getUserConfig()).hideContextMenu) return 41 | 42 | await getPreferredLanguageKey().then((lang) => { 43 | changeLanguage(lang) 44 | }) 45 | Browser.contextMenus.create({ 46 | id: menuId, 47 | title: 'ChatGPTBox', 48 | contexts: ['all'], 49 | }) 50 | 51 | for (const [k, v] of Object.entries(menuConfig)) { 52 | Browser.contextMenus.create({ 53 | id: menuId + k, 54 | parentId: menuId, 55 | title: t(v.label), 56 | contexts: ['all'], 57 | }) 58 | } 59 | Browser.contextMenus.create({ 60 | id: menuId + 'separator1', 61 | parentId: menuId, 62 | contexts: ['selection'], 63 | type: 'separator', 64 | }) 65 | for (const index in defaultConfig.selectionTools) { 66 | const key = defaultConfig.selectionTools[index] 67 | const desc = defaultConfig.selectionToolsDesc[index] 68 | Browser.contextMenus.create({ 69 | id: menuId + key, 70 | parentId: menuId, 71 | title: t(desc), 72 | contexts: ['selection'], 73 | }) 74 | } 75 | 76 | Browser.contextMenus.onClicked.addListener(onClickMenu) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /src/services/local-session.mjs: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { initSession } from './init-session.mjs' 3 | import { getUserConfig } from '../config/index.mjs' 4 | 5 | export const initDefaultSession = async () => { 6 | const config = await getUserConfig() 7 | return initSession({ 8 | sessionName: new Date().toLocaleString(), 9 | modelName: config.modelName, 10 | apiMode: config.apiMode, 11 | autoClean: false, 12 | extraCustomModelName: config.customModelName, 13 | }) 14 | } 15 | 16 | export const createSession = async (newSession) => { 17 | let currentSessions 18 | if (newSession) { 19 | const ret = await getSession(newSession.sessionId) 20 | currentSessions = ret.currentSessions 21 | if (ret.session) 22 | currentSessions[ 23 | currentSessions.findIndex((session) => session.sessionId === newSession.sessionId) 24 | ] = newSession 25 | else currentSessions.unshift(newSession) 26 | } else { 27 | newSession = await initDefaultSession() 28 | currentSessions = await getSessions() 29 | currentSessions.unshift(newSession) 30 | } 31 | await Browser.storage.local.set({ sessions: currentSessions }) 32 | return { session: newSession, currentSessions } 33 | } 34 | 35 | export const deleteSession = async (sessionId) => { 36 | const currentSessions = await getSessions() 37 | const index = currentSessions.findIndex((session) => session.sessionId === sessionId) 38 | currentSessions.splice(index, 1) 39 | if (currentSessions.length > 0) { 40 | await Browser.storage.local.set({ sessions: currentSessions }) 41 | return currentSessions 42 | } 43 | return await resetSessions() 44 | } 45 | 46 | export const getSession = async (sessionId) => { 47 | const currentSessions = await getSessions() 48 | return { 49 | session: currentSessions.find((session) => session.sessionId === sessionId), 50 | currentSessions, 51 | } 52 | } 53 | 54 | export const updateSession = async (newSession) => { 55 | newSession.updatedAt = new Date().toISOString() 56 | const currentSessions = await getSessions() 57 | currentSessions[ 58 | currentSessions.findIndex((session) => session.sessionId === newSession.sessionId) 59 | ] = newSession 60 | await Browser.storage.local.set({ sessions: currentSessions }) 61 | return currentSessions 62 | } 63 | 64 | export const resetSessions = async () => { 65 | const currentSessions = [await initDefaultSession()] 66 | await Browser.storage.local.set({ sessions: currentSessions }) 67 | return currentSessions 68 | } 69 | 70 | export const getSessions = async () => { 71 | const { sessions } = await Browser.storage.local.get('sessions') 72 | if (sessions && sessions.length > 0) return sessions 73 | return await resetSessions() 74 | } 75 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/bilibili/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText, waitForElementToExistAndSelect } from '../../../utils' 2 | import { config } from '../index.mjs' 3 | 4 | export default { 5 | init: async (hostname, userConfig, getInput, mountComponent) => { 6 | if (location.pathname.includes('/bangumi')) return false 7 | try { 8 | // B站页面是SSR的,如果插入过早,页面 js 检测到实际 Dom 和期望 Dom 不一致,会导致重新渲染 9 | await waitForElementToExistAndSelect('img.bili-avatar-img') 10 | const getVideoPath = () => 11 | location.pathname + `?p=${new URLSearchParams(location.search).get('p') || 1}` 12 | let oldPath = getVideoPath() 13 | const checkPathChange = async () => { 14 | const newPath = getVideoPath() 15 | if (newPath !== oldPath) { 16 | oldPath = newPath 17 | mountComponent('bilibili', config.bilibili) 18 | } 19 | } 20 | window.setInterval(checkPathChange, 500) 21 | } catch (e) { 22 | /* empty */ 23 | } 24 | return true 25 | }, 26 | inputQuery: async () => { 27 | try { 28 | const bvid = location.pathname.replace('video', '').replaceAll('/', '') 29 | const p = Number(new URLSearchParams(location.search).get('p') || 1) - 1 30 | 31 | const pagelistResponse = await fetch( 32 | `https://api.bilibili.com/x/player/pagelist?bvid=${bvid}`, 33 | ) 34 | const pagelistData = await pagelistResponse.json() 35 | const videoList = pagelistData.data 36 | const cid = videoList[p].cid 37 | const title = videoList[p].part 38 | 39 | const infoResponse = await fetch( 40 | `https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`, 41 | { 42 | credentials: 'include', 43 | }, 44 | ) 45 | const infoData = await infoResponse.json() 46 | let subtitleUrl = infoData.data.subtitle.subtitles[0].subtitle_url 47 | if (subtitleUrl.startsWith('//')) subtitleUrl = 'https:' + subtitleUrl 48 | else if (!subtitleUrl.startsWith('http')) subtitleUrl = 'https://' + subtitleUrl 49 | 50 | const subtitleResponse = await fetch(subtitleUrl) 51 | const subtitleData = await subtitleResponse.json() 52 | const subtitles = subtitleData.body 53 | 54 | let subtitleContent = '' 55 | for (let i = 0; i < subtitles.length; i++) { 56 | if (i === subtitles.length - 1) subtitleContent += subtitles[i].content 57 | else subtitleContent += subtitles[i].content + ',' 58 | } 59 | 60 | return await cropText( 61 | `You are an expert video summarizer. Create a comprehensive summary of the following Bilibili video in markdown format, ` + 62 | `highlighting key takeaways, crucial information, and main topics. Include the video title.\n` + 63 | `Video Title: "${title}"\n` + 64 | `Subtitle content:\n${subtitleContent}`, 65 | ) 66 | } catch (e) { 67 | console.log(e) 68 | } 69 | }, 70 | } 71 | -------------------------------------------------------------------------------- /src/services/apis/claude-api.mjs: -------------------------------------------------------------------------------- 1 | import { getUserConfig } from '../../config/index.mjs' 2 | import { pushRecord, setAbortController } from './shared.mjs' 3 | import { fetchSSE } from '../../utils/fetch-sse.mjs' 4 | import { isEmpty } from 'lodash-es' 5 | import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' 6 | import { getModelValue } from '../../utils/model-name-convert.mjs' 7 | 8 | /** 9 | * @param {Runtime.Port} port 10 | * @param {string} question 11 | * @param {Session} session 12 | */ 13 | export async function generateAnswersWithClaudeApi(port, question, session) { 14 | const { controller, messageListener, disconnectListener } = setAbortController(port) 15 | const config = await getUserConfig() 16 | const apiUrl = config.customClaudeApiUrl 17 | const model = getModelValue(session) 18 | 19 | const prompt = getConversationPairs( 20 | session.conversationRecords.slice(-config.maxConversationContextLength), 21 | false, 22 | ) 23 | prompt.push({ role: 'user', content: question }) 24 | 25 | let answer = '' 26 | await fetchSSE(`${apiUrl}/v1/messages`, { 27 | method: 'POST', 28 | signal: controller.signal, 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | 'anthropic-version': '2023-06-01', 32 | 'x-api-key': config.claudeApiKey, 33 | 'anthropic-dangerous-direct-browser-access': true, 34 | }, 35 | body: JSON.stringify({ 36 | model, 37 | messages: prompt, 38 | stream: true, 39 | max_tokens: config.maxResponseTokenLength, 40 | temperature: config.temperature, 41 | }), 42 | onMessage(message) { 43 | console.debug('sse message', message) 44 | 45 | let data 46 | try { 47 | data = JSON.parse(message) 48 | } catch (error) { 49 | console.debug('json error', error) 50 | return 51 | } 52 | if (data?.type === 'message_stop') { 53 | pushRecord(session, question, answer) 54 | console.debug('conversation history', { content: session.conversationRecords }) 55 | port.postMessage({ answer: null, done: true, session: session }) 56 | return 57 | } 58 | 59 | const delta = data?.delta?.text 60 | if (delta) { 61 | answer += delta 62 | port.postMessage({ answer: answer, done: false, session: null }) 63 | } 64 | }, 65 | async onStart() {}, 66 | async onEnd() { 67 | port.postMessage({ done: true }) 68 | port.onMessage.removeListener(messageListener) 69 | port.onDisconnect.removeListener(disconnectListener) 70 | }, 71 | async onError(resp) { 72 | port.onMessage.removeListener(messageListener) 73 | port.onDisconnect.removeListener(disconnectListener) 74 | if (resp instanceof Error) throw resp 75 | const error = await resp.json().catch(() => ({})) 76 | throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) 77 | }, 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChatGPTBox", 3 | "description": "Integrating ChatGPT into your browser deeply, everything you need is here", 4 | "version": "2.5.9", 5 | "manifest_version": 3, 6 | "icons": { 7 | "16": "logo.png", 8 | "32": "logo.png", 9 | "48": "logo.png", 10 | "128": "logo.png" 11 | }, 12 | "host_permissions": [ 13 | "https://*.chatgpt.com/*", 14 | "https://*.openai.com/*", 15 | "https://*.bing.com/*", 16 | "https://*.poe.com/*", 17 | "https://*.google.com/*", 18 | "https://claude.ai/*", 19 | "https://*.moonshot.cn/*", 20 | "" 21 | ], 22 | "permissions": [ 23 | "cookies", 24 | "storage", 25 | "contextMenus", 26 | "unlimitedStorage", 27 | "tabs", 28 | "webRequest", 29 | "declarativeNetRequestWithHostAccess", 30 | "sidePanel" 31 | ], 32 | "optional_permissions": [ 33 | "background" 34 | ], 35 | "background": { 36 | "service_worker": "background.js" 37 | }, 38 | "action": { 39 | "default_popup": "popup.html" 40 | }, 41 | "side_panel": { 42 | "default_path": "IndependentPanel.html" 43 | }, 44 | "declarative_net_request": { 45 | "rule_resources": [ 46 | { 47 | "id": "ruleset", 48 | "enabled": true, 49 | "path": "rules.json" 50 | } 51 | ] 52 | }, 53 | "options_ui": { 54 | "page": "popup.html", 55 | "open_in_tab": true 56 | }, 57 | "content_scripts": [ 58 | { 59 | "matches": [ 60 | "https://*/*", 61 | "http://*/*", 62 | "file://*/*" 63 | ], 64 | "js": [ 65 | "shared.js", 66 | "content-script.js" 67 | ], 68 | "css": [ 69 | "content-script.css" 70 | ] 71 | } 72 | ], 73 | "web_accessible_resources": [ 74 | { 75 | "resources": [ 76 | "logo.png" 77 | ], 78 | "matches": [ 79 | "" 80 | ] 81 | } 82 | ], 83 | "commands": { 84 | "newChat": { 85 | "suggested_key": { 86 | "default": "Ctrl+B", 87 | "mac": "MacCtrl+B" 88 | }, 89 | "description": "Create a new chat" 90 | }, 91 | "summarizePage": { 92 | "suggested_key": { 93 | "default": "Alt+B", 94 | "mac": "Alt+B" 95 | }, 96 | "description": "Summarize this page" 97 | }, 98 | "openConversationPage": { 99 | "suggested_key": { 100 | "default": "Ctrl+Shift+H", 101 | "mac": "MacCtrl+Shift+H" 102 | }, 103 | "description": "Open the independent conversation page" 104 | }, 105 | "openConversationWindow": { 106 | "description": "Open the independent conversation window" 107 | }, 108 | "openSidePanel": { 109 | "description": "Open the independent conversation side panel" 110 | }, 111 | "closeAllChats": { 112 | "description": "Close all chats in this page" 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /src/content-script/menu-tools/index.mjs: -------------------------------------------------------------------------------- 1 | import { getCoreContentText } from '../../utils/get-core-content-text' 2 | import Browser from 'webextension-polyfill' 3 | import { getUserConfig } from '../../config/index.mjs' 4 | import { openUrl } from '../../utils/open-url' 5 | 6 | export const config = { 7 | newChat: { 8 | label: 'New Chat', 9 | genPrompt: async () => { 10 | return '' 11 | }, 12 | }, 13 | summarizePage: { 14 | label: 'Summarize Page', 15 | genPrompt: async () => { 16 | return `You are an expert summarizer. Carefully analyze the following web page content and provide a concise summary focusing on the key points:\n${getCoreContentText()}` 17 | }, 18 | }, 19 | openConversationPage: { 20 | label: 'Open Conversation Page', 21 | action: async (fromBackground) => { 22 | console.debug('action is from background', fromBackground) 23 | if (fromBackground) { 24 | openUrl(Browser.runtime.getURL('IndependentPanel.html')) 25 | } else { 26 | Browser.runtime.sendMessage({ 27 | type: 'OPEN_URL', 28 | data: { 29 | url: Browser.runtime.getURL('IndependentPanel.html'), 30 | }, 31 | }) 32 | } 33 | }, 34 | }, 35 | openConversationWindow: { 36 | label: 'Open Conversation Window', 37 | action: async (fromBackground) => { 38 | console.debug('action is from background', fromBackground) 39 | if (fromBackground) { 40 | const config = await getUserConfig() 41 | const url = Browser.runtime.getURL('IndependentPanel.html') 42 | const tabs = await Browser.tabs.query({ url: url, windowType: 'popup' }) 43 | if (!config.alwaysCreateNewConversationWindow && tabs.length > 0) 44 | await Browser.windows.update(tabs[0].windowId, { focused: true }) 45 | else 46 | await Browser.windows.create({ 47 | url: url, 48 | type: 'popup', 49 | width: 500, 50 | height: 650, 51 | }) 52 | } else { 53 | Browser.runtime.sendMessage({ 54 | type: 'OPEN_CHAT_WINDOW', 55 | data: {}, 56 | }) 57 | } 58 | }, 59 | }, 60 | openSidePanel: { 61 | label: 'Open Side Panel', 62 | action: async (fromBackground, tab) => { 63 | console.debug('action is from background', fromBackground) 64 | if (fromBackground) { 65 | // eslint-disable-next-line no-undef 66 | chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) 67 | } else { 68 | // side panel is not supported 69 | } 70 | }, 71 | }, 72 | closeAllChats: { 73 | label: 'Close All Chats In This Page', 74 | action: async (fromBackground) => { 75 | console.debug('action is from background', fromBackground) 76 | Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { 77 | Browser.tabs.sendMessage(tabs[0].id, { 78 | type: 'CLOSE_CHATS', 79 | data: {}, 80 | }) 81 | }) 82 | }, 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /src/components/WebJumpBackNotification/index.jsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import PropTypes from 'prop-types' 3 | import Browser from 'webextension-polyfill' 4 | import { toast, ToastContainer } from 'react-toastify' 5 | import { useEffect } from 'react' 6 | import 'react-toastify/dist/ReactToastify.css' 7 | import { useTheme } from '../../hooks/use-theme.mjs' 8 | import { getUserConfig } from '../../config/index.mjs' 9 | 10 | const WebJumpBackNotification = (props) => { 11 | const { t } = useTranslation() 12 | const [theme, config] = useTheme() 13 | 14 | const buttonStyle = { 15 | padding: '0 8px', 16 | border: '1px solid', 17 | borderRadius: '4px', 18 | whiteSpace: 'nowrap', 19 | cursor: 'pointer', 20 | color: 'inherit', 21 | backgroundColor: 'transparent', 22 | } 23 | 24 | useEffect(() => { 25 | toast( 26 |
35 |
36 | {props.chatgptMode 37 | ? t('Please keep this tab open. You can now use the web mode of ChatGPTBox') 38 | : t('You have successfully logged in for ChatGPTBox and can now return')} 39 |
40 |
41 | {props.chatgptMode && ( 42 | 55 | )} 56 | 69 |
70 |
, 71 | { 72 | toastId: 0, 73 | updateId: 0, 74 | }, 75 | ) 76 | }, [config.themeMode, config.preferredLanguage]) 77 | 78 | return ( 79 | 92 | ) 93 | } 94 | 95 | WebJumpBackNotification.propTypes = { 96 | container: PropTypes.object.isRequired, 97 | chatgptMode: PropTypes.bool, 98 | } 99 | 100 | export default WebJumpBackNotification 101 | -------------------------------------------------------------------------------- /src/utils/get-core-content-text.mjs: -------------------------------------------------------------------------------- 1 | import { getPossibleElementByQuerySelector } from './get-possible-element-by-query-selector.mjs' 2 | import { Readability, isProbablyReaderable } from '@mozilla/readability' 3 | 4 | const adapters = { 5 | 'scholar.google': ['#gs_res_ccl_mid'], 6 | google: ['#search'], 7 | csdn: ['#content_views'], 8 | bing: ['#b_results'], 9 | wikipedia: ['#mw-content-text'], 10 | faz: ['.atc-Text'], 11 | golem: ['article'], 12 | eetimes: ['article'], 13 | 'new.qq.com': ['.content-article'], 14 | } 15 | 16 | function getArea(e) { 17 | const rect = e.getBoundingClientRect() 18 | return rect.width * rect.height 19 | } 20 | 21 | function findLargestElement(e) { 22 | if (!e) { 23 | return null 24 | } 25 | let maxArea = 0 26 | let largestElement = null 27 | const limitedArea = 0.8 * getArea(e) 28 | 29 | function traverseDOM(node) { 30 | if (node.nodeType === Node.ELEMENT_NODE) { 31 | const area = getArea(node) 32 | 33 | if (area > maxArea && area < limitedArea) { 34 | maxArea = area 35 | largestElement = node 36 | } 37 | 38 | Array.from(node.children).forEach(traverseDOM) 39 | } 40 | } 41 | 42 | traverseDOM(e) 43 | return largestElement 44 | } 45 | 46 | function getTextFrom(e) { 47 | return e.innerText || e.textContent 48 | } 49 | 50 | function postProcessText(text) { 51 | return text 52 | .trim() 53 | .replaceAll(' ', '') 54 | .replaceAll('\t', '') 55 | .replaceAll('\n\n', '') 56 | .replaceAll(',,', '') 57 | } 58 | 59 | export function getCoreContentText() { 60 | for (const [siteName, selectors] of Object.entries(adapters)) { 61 | if (location.hostname.includes(siteName)) { 62 | const element = getPossibleElementByQuerySelector(selectors) 63 | if (element) return postProcessText(getTextFrom(element)) 64 | break 65 | } 66 | } 67 | 68 | const element = document.querySelector('article') 69 | if (element) { 70 | return postProcessText(getTextFrom(element)) 71 | } 72 | 73 | if (isProbablyReaderable(document)) { 74 | let article = new Readability(document.cloneNode(true), { 75 | keepClasses: true, 76 | }).parse() 77 | if (article?.textContent) { 78 | console.log('readerable: successfully extracted content') 79 | return postProcessText(article.textContent) 80 | } else { 81 | console.log('readerable: parsing failed despite probability check') 82 | } 83 | } 84 | 85 | const largestElement = findLargestElement(document.body) 86 | const secondLargestElement = findLargestElement(largestElement) 87 | console.log(largestElement) 88 | console.log(secondLargestElement) 89 | 90 | let ret 91 | if (!largestElement) { 92 | ret = getTextFrom(document.body) 93 | console.log('use document.body') 94 | } else if ( 95 | secondLargestElement && 96 | getArea(secondLargestElement) > 0.5 * getArea(largestElement) 97 | ) { 98 | ret = getTextFrom(secondLargestElement) 99 | console.log('use second') 100 | } else { 101 | ret = getTextFrom(largestElement) 102 | console.log('use first') 103 | } 104 | return postProcessText(ret) 105 | } 106 | -------------------------------------------------------------------------------- /src/services/apis/waylaidwanderer-api.mjs: -------------------------------------------------------------------------------- 1 | import { pushRecord, setAbortController } from './shared.mjs' 2 | import { getUserConfig } from '../../config/index.mjs' 3 | import { fetchSSE } from '../../utils/fetch-sse.mjs' 4 | import { isEmpty } from 'lodash-es' 5 | 6 | /** 7 | * @param {Runtime.Port} port 8 | * @param {string} question 9 | * @param {Session} session 10 | */ 11 | export async function generateAnswersWithWaylaidwandererApi(port, question, session) { 12 | const { controller, messageListener, disconnectListener } = setAbortController(port) 13 | 14 | const config = await getUserConfig() 15 | 16 | let answer = '' 17 | await fetchSSE(config.githubThirdPartyUrl, { 18 | method: 'POST', 19 | signal: controller.signal, 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | }, 23 | body: JSON.stringify({ 24 | message: question, 25 | stream: true, 26 | ...(session.bingWeb_encryptedConversationSignature && { 27 | conversationId: session.bingWeb_conversationId, 28 | encryptedConversationSignature: session.bingWeb_encryptedConversationSignature, 29 | clientId: session.bingWeb_clientId, 30 | invocationId: session.bingWeb_invocationId, 31 | }), 32 | ...(session.parentMessageId && { 33 | conversationId: session.conversationId, 34 | parentMessageId: session.parentMessageId, 35 | }), 36 | }), 37 | onMessage(message) { 38 | console.debug('sse message', message) 39 | if (message.trim() === '[DONE]') { 40 | pushRecord(session, question, answer) 41 | console.debug('conversation history', { content: session.conversationRecords }) 42 | port.postMessage({ answer: null, done: true, session: session }) 43 | return 44 | } 45 | let data 46 | try { 47 | data = JSON.parse(message) 48 | } catch (error) { 49 | console.debug('json error', error) 50 | return 51 | } 52 | if (data.conversationId) session.conversationId = data.conversationId 53 | if (data.parentMessageId) session.parentMessageId = data.parentMessageId 54 | if (data.encryptedConversationSignature) 55 | session.bingWeb_encryptedConversationSignature = data.encryptedConversationSignature 56 | if (data.conversationId) session.bingWeb_conversationId = data.conversationId 57 | if (data.clientId) session.bingWeb_clientId = data.clientId 58 | if (data.invocationId) session.bingWeb_invocationId = data.invocationId 59 | 60 | if (typeof data === 'string') { 61 | answer += data 62 | port.postMessage({ answer: answer, done: false, session: null }) 63 | } 64 | }, 65 | async onStart() {}, 66 | async onEnd() { 67 | port.postMessage({ done: true }) 68 | port.onMessage.removeListener(messageListener) 69 | port.onDisconnect.removeListener(disconnectListener) 70 | }, 71 | async onError(resp) { 72 | port.onMessage.removeListener(messageListener) 73 | port.onDisconnect.removeListener(disconnectListener) 74 | if (resp instanceof Error) throw resp 75 | const error = await resp.json().catch(() => ({})) 76 | throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) 77 | }, 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /src/popup/sections/FeaturePages.jsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { useState } from 'react' 3 | import { isEdge, isFirefox, isMobile, isSafari, openUrl } from '../../utils/index.mjs' 4 | import Browser from 'webextension-polyfill' 5 | import PropTypes from 'prop-types' 6 | 7 | FeaturePages.propTypes = { 8 | config: PropTypes.object.isRequired, 9 | updateConfig: PropTypes.func.isRequired, 10 | } 11 | 12 | export function FeaturePages({ config, updateConfig }) { 13 | const { t } = useTranslation() 14 | const [backgroundPermission, setBackgroundPermission] = useState(false) 15 | 16 | if (!isMobile() && !isFirefox() && !isSafari()) 17 | Browser.permissions.contains({ permissions: ['background'] }).then((result) => { 18 | setBackgroundPermission(result) 19 | }) 20 | 21 | return ( 22 |
23 | {!isMobile() && !isFirefox() && !isSafari() && ( 24 | 33 | )} 34 | 47 | {!isMobile() && ( 48 | 59 | )} 60 | {!isMobile() && !isFirefox() && !isSafari() && ( 61 | 79 | )} 80 | {!isMobile() && ( 81 | 92 | )} 93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/services/apis/azure-openai-api.mjs: -------------------------------------------------------------------------------- 1 | import { getUserConfig } from '../../config/index.mjs' 2 | import { pushRecord, setAbortController } from './shared.mjs' 3 | import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' 4 | import { fetchSSE } from '../../utils/fetch-sse.mjs' 5 | import { isEmpty } from 'lodash-es' 6 | import { getModelValue } from '../../utils/model-name-convert.mjs' 7 | 8 | /** 9 | * @param {Runtime.Port} port 10 | * @param {string} question 11 | * @param {Session} session 12 | */ 13 | export async function generateAnswersWithAzureOpenaiApi(port, question, session) { 14 | const { controller, messageListener, disconnectListener } = setAbortController(port) 15 | const config = await getUserConfig() 16 | let model = getModelValue(session) 17 | if (!model) model = config.azureDeploymentName 18 | 19 | const prompt = getConversationPairs( 20 | session.conversationRecords.slice(-config.maxConversationContextLength), 21 | false, 22 | ) 23 | prompt.push({ role: 'user', content: question }) 24 | 25 | let answer = '' 26 | await fetchSSE( 27 | `${config.azureEndpoint.replace( 28 | /\/$/, 29 | '', 30 | )}/openai/deployments/${model}/chat/completions?api-version=2024-02-01`, 31 | { 32 | method: 'POST', 33 | signal: controller.signal, 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | 'api-key': config.azureApiKey, 37 | }, 38 | body: JSON.stringify({ 39 | messages: prompt, 40 | stream: true, 41 | max_tokens: config.maxResponseTokenLength, 42 | temperature: config.temperature, 43 | }), 44 | onMessage(message) { 45 | console.debug('sse message', message) 46 | let data 47 | try { 48 | data = JSON.parse(message) 49 | } catch (error) { 50 | console.debug('json error', error) 51 | return 52 | } 53 | if ( 54 | data.choices && 55 | data.choices.length > 0 && 56 | data.choices[0] && 57 | data.choices[0].delta && 58 | 'content' in data.choices[0].delta 59 | ) { 60 | answer += data.choices[0].delta.content 61 | port.postMessage({ answer: answer, done: false, session: null }) 62 | } 63 | 64 | if (data.choices && data.choices.length > 0 && data.choices[0]?.finish_reason) { 65 | pushRecord(session, question, answer) 66 | console.debug('conversation history', { content: session.conversationRecords }) 67 | port.postMessage({ answer: null, done: true, session: session }) 68 | } 69 | }, 70 | async onStart() {}, 71 | async onEnd() { 72 | port.postMessage({ done: true }) 73 | port.onMessage.removeListener(messageListener) 74 | port.onDisconnect.removeListener(disconnectListener) 75 | }, 76 | async onError(resp) { 77 | port.onMessage.removeListener(messageListener) 78 | port.onDisconnect.removeListener(disconnectListener) 79 | if (resp instanceof Error) throw resp 80 | const error = await resp.json().catch(() => ({})) 81 | throw new Error( 82 | !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`, 83 | ) 84 | }, 85 | }, 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgptbox", 3 | "scripts": { 4 | "build": "node build.mjs --production", 5 | "build:safari": "bash ./safari/build.sh", 6 | "dev": "node build.mjs --development", 7 | "analyze": "node build.mjs --analyze", 8 | "lint": "eslint --ext .js,.mjs,.jsx .", 9 | "lint:fix": "eslint --ext .js,.mjs,.jsx . --fix", 10 | "pretty": "prettier --write ./**/*.{js,mjs,jsx,json,css,scss}", 11 | "stage": "run-script-os", 12 | "stage:default": "git add $(git diff --name-only --cached --diff-filter=d)", 13 | "stage:win32": "powershell git add $(git diff --name-only --cached --diff-filter=d)", 14 | "verify": "node .github/workflows/scripts/verify-search-engine-configs.mjs" 15 | }, 16 | "pre-commit": [ 17 | "pretty", 18 | "stage", 19 | "lint" 20 | ], 21 | "dependencies": { 22 | "@mozilla/readability": "^0.6.0", 23 | "@nem035/gpt-3-encoder": "^1.1.7", 24 | "@picocss/pico": "^1.5.13", 25 | "@primer/octicons-react": "^18.3.0", 26 | "buffer": "^6.0.3", 27 | "countries-list": "^2.6.1", 28 | "crypto-browserify": "^3.12.0", 29 | "diff": "^5.2.0", 30 | "file-saver": "^2.0.5", 31 | "github-markdown-css": "^5.6.1", 32 | "gpt-3-encoder": "^1.1.4", 33 | "graphql": "^16.9.0", 34 | "i18next": "^22.4.15", 35 | "js-sha3": "^0.9.3", 36 | "jsonwebtoken": "9.0.2", 37 | "katex": "^0.16.11", 38 | "lodash-es": "^4.17.21", 39 | "md5": "^2.3.0", 40 | "parse5": "^6.0.1", 41 | "preact": "^10.22.1", 42 | "process": "^0.11.10", 43 | "prop-types": "^15.8.1", 44 | "random-int": "^3.0.0", 45 | "react": "npm:@preact/compat@^17.1.2", 46 | "react-bootstrap-icons": "^1.11.4", 47 | "react-dom": "npm:@preact/compat@^17.1.2", 48 | "react-draggable": "^4.4.6", 49 | "react-i18next": "^12.2.0", 50 | "react-markdown": "^8.0.7", 51 | "react-tabs": "^4.3.0", 52 | "react-toastify": "^9.1.3", 53 | "rehype-highlight": "^6.0.0", 54 | "rehype-katex": "^6.0.3", 55 | "rehype-raw": "^6.1.1", 56 | "remark-breaks": "^3.0.3", 57 | "remark-gfm": "^3.0.1", 58 | "remark-math": "^5.1.1", 59 | "stream-browserify": "^3.0.0", 60 | "util": "^0.12.5", 61 | "uuid": "^9.0.1", 62 | "webextension-polyfill": "^0.12.0" 63 | }, 64 | "devDependencies": { 65 | "@babel/core": "^7.24.7", 66 | "@babel/plugin-transform-react-jsx": "^7.24.7", 67 | "@babel/plugin-transform-runtime": "^7.24.7", 68 | "@babel/preset-env": "^7.24.7", 69 | "@types/archiver": "^5.3.4", 70 | "@types/fs-extra": "^11.0.4", 71 | "@types/jsdom": "^21.1.7", 72 | "@types/webextension-polyfill": "^0.10.7", 73 | "archiver": "^5.3.2", 74 | "babel-loader": "^9.1.3", 75 | "css-loader": "^6.11.0", 76 | "css-minimizer-webpack-plugin": "^5.0.1", 77 | "eslint": "^8.57.1", 78 | "eslint-plugin-react": "^7.34.3", 79 | "fs-extra": "^11.2.0", 80 | "graphql-tag": "^2.12.6", 81 | "jsdom": "^21.1.2", 82 | "less-loader": "^11.1.4", 83 | "mini-css-extract-plugin": "^2.9.0", 84 | "node-fetch": "^3.3.2", 85 | "pre-commit": "^1.2.2", 86 | "prettier": "^2.8.8", 87 | "progress-bar-webpack-plugin": "^2.1.0", 88 | "run-script-os": "^1.1.6", 89 | "sass": "^1.77.6", 90 | "sass-loader": "^13.3.3", 91 | "string-replace-loader": "^3.1.0", 92 | "terser-webpack-plugin": "^5.3.10", 93 | "webpack": "^5.92.1", 94 | "webpack-bundle-analyzer": "^4.10.2" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/services/init-session.mjs: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid' 2 | import { apiModeToModelName, modelNameToDesc } from '../utils/model-name-convert.mjs' 3 | import { t } from 'i18next' 4 | 5 | /** 6 | * @typedef {object} Session 7 | * @property {string|null} question 8 | * @property {Object[]|null} conversationRecords 9 | * @property {string|null} sessionName 10 | * @property {string|null} sessionId 11 | * @property {string|null} createdAt 12 | * @property {string|null} updatedAt 13 | * @property {string|null} aiName 14 | * @property {string|null} modelName 15 | * @property {boolean|null} autoClean 16 | * @property {boolean} isRetry 17 | * @property {string|null} conversationId - chatGPT web mode 18 | * @property {string|null} messageId - chatGPT web mode 19 | * @property {string|null} parentMessageId - chatGPT web mode 20 | * @property {string|null} wsRequestId - chatGPT web mode 21 | * @property {string|null} bingWeb_encryptedConversationSignature 22 | * @property {string|null} bingWeb_conversationId 23 | * @property {string|null} bingWeb_clientId 24 | * @property {string|null} bingWeb_invocationId 25 | * @property {string|null} bingWeb_jailbreakConversationId 26 | * @property {string|null} bingWeb_parentMessageId 27 | * @property {Object|null} bingWeb_jailbreakConversationCache 28 | * @property {number|null} poe_chatId 29 | * @property {object|null} bard_conversationObj 30 | * @property {object|null} claude_conversation 31 | * @property {object|null} moonshot_conversation 32 | */ 33 | /** 34 | * @param {string|null} question 35 | * @param {Object[]|null} conversationRecords 36 | * @param {string|null} sessionName 37 | * @param {string|null} modelName 38 | * @param {boolean|null} autoClean 39 | * @param {Object|null} apiMode 40 | * @param {string} extraCustomModelName 41 | * @returns {Session} 42 | */ 43 | export function initSession({ 44 | question = null, 45 | conversationRecords = [], 46 | sessionName = null, 47 | modelName = null, 48 | autoClean = false, 49 | apiMode = null, 50 | extraCustomModelName = '', 51 | } = {}) { 52 | return { 53 | // common 54 | question, 55 | conversationRecords, 56 | 57 | sessionName, 58 | sessionId: uuidv4(), 59 | createdAt: new Date().toISOString(), 60 | updatedAt: new Date().toISOString(), 61 | 62 | aiName: 63 | modelName || apiMode 64 | ? modelNameToDesc( 65 | apiMode ? apiModeToModelName(apiMode) : modelName, 66 | t, 67 | extraCustomModelName, 68 | ) 69 | : null, 70 | modelName, 71 | apiMode, 72 | 73 | autoClean, 74 | isRetry: false, 75 | 76 | // chatgpt-web 77 | conversationId: null, 78 | messageId: null, 79 | parentMessageId: null, 80 | wsRequestId: null, 81 | 82 | // bing 83 | bingWeb_encryptedConversationSignature: null, 84 | bingWeb_conversationId: null, 85 | bingWeb_clientId: null, 86 | bingWeb_invocationId: null, 87 | 88 | // bing sydney 89 | bingWeb_jailbreakConversationId: null, 90 | bingWeb_parentMessageId: null, 91 | bingWeb_jailbreakConversationCache: null, 92 | 93 | // poe 94 | poe_chatId: null, 95 | 96 | // bard 97 | bard_conversationObj: null, 98 | 99 | // claude.ai 100 | claude_conversation: null, 101 | // kimi.com 102 | moonshot_conversation: null, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/content-script/site-adapters/youtube/index.mjs: -------------------------------------------------------------------------------- 1 | import { cropText } from '../../../utils' 2 | import { config } from '../index.mjs' 3 | 4 | // This function was written by ChatGPT and modified by iamsirsammy 5 | function replaceHtmlEntities(htmlString) { 6 | const doc = new DOMParser().parseFromString(htmlString.replaceAll('&', '&'), 'text/html') 7 | return doc.documentElement.innerText 8 | } 9 | 10 | export default { 11 | init: async (hostname, userConfig, getInput, mountComponent) => { 12 | try { 13 | let oldUrl = location.href 14 | const checkUrlChange = async () => { 15 | if (location.href !== oldUrl) { 16 | oldUrl = location.href 17 | mountComponent('youtube', config.youtube) 18 | } 19 | } 20 | window.setInterval(checkUrlChange, 500) 21 | } catch (e) { 22 | /* empty */ 23 | } 24 | return true 25 | }, 26 | inputQuery: async () => { 27 | try { 28 | const docText = await ( 29 | await fetch(location.href, { 30 | credentials: 'include', 31 | }) 32 | ).text() 33 | 34 | const subtitleUrlStartAt = docText.indexOf('https://www.youtube.com/api/timedtext') 35 | if (subtitleUrlStartAt === -1) return 36 | 37 | let subtitleUrl = docText.substring(subtitleUrlStartAt) 38 | subtitleUrl = subtitleUrl.substring(0, subtitleUrl.indexOf('"')) 39 | subtitleUrl = subtitleUrl.replaceAll('\\u0026', '&') 40 | 41 | let title = docText.substring(docText.indexOf('"title":"') + '"title":"'.length) 42 | title = title.substring(0, title.indexOf('","')) 43 | 44 | let potokenSource = performance 45 | .getEntriesByType('resource') 46 | .filter((a) => a?.name.includes('/api/timedtext?')) 47 | .pop() 48 | if (!potokenSource) { 49 | //TODO use waitUntil function in refactor version 50 | await new Promise((r) => setTimeout(r, 500)) 51 | document.querySelector('button.ytp-subtitles-button.ytp-button').click() 52 | await new Promise((r) => setTimeout(r, 100)) 53 | document.querySelector('button.ytp-subtitles-button.ytp-button').click() 54 | } 55 | await new Promise((r) => setTimeout(r, 500)) 56 | potokenSource = performance 57 | .getEntriesByType('resource') 58 | .filter((a) => a?.name.includes('/api/timedtext?')) 59 | .pop() 60 | if (!potokenSource) return 61 | const potoken = new URL(potokenSource.name).searchParams.get('pot') 62 | 63 | const subtitleResponse = await fetch(`${subtitleUrl}&pot=${potoken}&c=WEB`) 64 | if (!subtitleResponse.ok) return 65 | let subtitleData = await subtitleResponse.text() 66 | 67 | let subtitleContent = '' 68 | while (subtitleData.indexOf('">') !== -1) { 69 | subtitleData = subtitleData.substring(subtitleData.indexOf('">') + 2) 70 | subtitleContent += subtitleData.substring(0, subtitleData.indexOf('<')) + ',' 71 | } 72 | 73 | subtitleContent = replaceHtmlEntities(subtitleContent) 74 | 75 | return await cropText( 76 | `You are an expert video summarizer. Create a comprehensive summary of the following YouTube video in markdown format, ` + 77 | `highlighting key takeaways, crucial information, and main topics. Include the video title.\n` + 78 | `Video Title: "${title}"\n` + 79 | `Subtitle content:\n${subtitleContent}`, 80 | ) 81 | } catch (e) { 82 | console.log(e) 83 | } 84 | }, 85 | } 86 | -------------------------------------------------------------------------------- /src/pages/IndependentPanel/styles.scss: -------------------------------------------------------------------------------- 1 | [data-theme='auto'] { 2 | @media screen and (prefers-color-scheme: dark) { 3 | --font-color: #c9d1d9; 4 | --font-active-color: #ffffff; 5 | --theme-color: #202124; 6 | --theme-border-color: #3c4043; 7 | --active-color: #3c4043; 8 | } 9 | @media screen and (prefers-color-scheme: light) { 10 | --font-color: #24292f; 11 | --font-active-color: #cc3333; 12 | --theme-color: #ffffff; 13 | --theme-border-color: #aeafb2; 14 | --active-color: #d0d4da; 15 | } 16 | } 17 | 18 | [data-theme='dark'] { 19 | --font-color: #c9d1d9; 20 | --font-active-color: #ffffff; 21 | --theme-color: #202124; 22 | --theme-border-color: #3c4043; 23 | --active-color: #3c4043; 24 | } 25 | 26 | [data-theme='light'] { 27 | --font-color: #24292f; 28 | --font-active-color: #cc3333; 29 | --theme-color: #ffffff; 30 | --theme-border-color: #aeafb2; 31 | --active-color: #d0d4da; 32 | } 33 | 34 | .IndependentPanel * { 35 | font-family: 'Cairo', sans-serif; 36 | font-size: 14px; 37 | } 38 | 39 | .IndependentPanel { 40 | .chat-container { 41 | display: flex; 42 | width: 100%; 43 | height: 100%; 44 | } 45 | 46 | .chat-sidebar { 47 | display: flex; 48 | flex-direction: column; 49 | min-width: 250px; 50 | width: 250px; 51 | background-color: var(--theme-color); 52 | transition: width 0.3s, min-width 0.3s; 53 | padding: 10px; 54 | 55 | ::-webkit-scrollbar { 56 | background-color: var(--theme-color); 57 | width: 9px; 58 | } 59 | ::-webkit-scrollbar-thumb { 60 | background-color: var(--theme-border-color); 61 | border-radius: 20px; 62 | border: transparent; 63 | } 64 | ::-webkit-scrollbar-corner { 65 | background: transparent; 66 | } 67 | } 68 | 69 | .chat-sidebar.collapsed { 70 | min-width: 60px; 71 | width: 60px; 72 | } 73 | 74 | .chat-sidebar:hover, 75 | .chat-sidebar:not(.collapsed) { 76 | min-width: 250px; 77 | width: 250px; 78 | } 79 | 80 | .chat-sidebar-button-group { 81 | display: flex; 82 | flex-direction: column; 83 | padding: 0; 84 | background-color: var(--theme-color); 85 | gap: 15px; 86 | } 87 | 88 | .chat-list { 89 | display: flex; 90 | flex-direction: column; 91 | flex-grow: 1; 92 | padding: 0 2px 0 0; 93 | background-color: var(--theme-color); 94 | overflow-y: auto; 95 | overflow-x: hidden; 96 | gap: 15px; 97 | } 98 | 99 | .chat-content { 100 | flex-grow: 1; 101 | border: 1px solid var(--theme-border-color); 102 | background-color: var(--theme-color); 103 | } 104 | 105 | .normal-button { 106 | width: 100%; 107 | min-height: 40px; 108 | padding: 1px 6px; 109 | border: 1px solid; 110 | border-color: var(--theme-border-color); 111 | background-color: var(--theme-color); 112 | color: var(--font-color); 113 | border-radius: 5px; 114 | cursor: pointer; 115 | white-space: nowrap; 116 | text-overflow: ellipsis; 117 | overflow: hidden; 118 | } 119 | 120 | .gpt-util-group { 121 | display: flex; 122 | gap: 15px; 123 | align-items: center; 124 | } 125 | 126 | .gpt-util-icon { 127 | display: flex; 128 | cursor: pointer; 129 | align-items: center; 130 | color: var(--font-color); 131 | } 132 | .gpt-util-icon:hover { 133 | color: var(--font-active-color); 134 | } 135 | 136 | .normal-button.active, 137 | .normal-button:hover { 138 | background-color: var(--active-color); 139 | } 140 | 141 | hr { 142 | height: 1px; 143 | background-color: var(--theme-border-color); 144 | border: none; 145 | margin: 15px 0; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/services/apis/bing-web.mjs: -------------------------------------------------------------------------------- 1 | import BingAIClient from '../clients/bing/index.mjs' 2 | import { getUserConfig } from '../../config/index.mjs' 3 | import { pushRecord, setAbortController } from './shared.mjs' 4 | import { getModelValue } from '../../utils/model-name-convert.mjs' 5 | 6 | /** 7 | * @param {Runtime.Port} port 8 | * @param {string} question 9 | * @param {Session} session 10 | * @param {string} accessToken 11 | * @param {boolean} sydneyMode 12 | */ 13 | export async function generateAnswersWithBingWebApi( 14 | port, 15 | question, 16 | session, 17 | accessToken, 18 | sydneyMode = false, 19 | ) { 20 | const { controller, messageListener, disconnectListener } = setAbortController(port) 21 | const config = await getUserConfig() 22 | let modelMode = getModelValue(session) 23 | if (!modelMode) modelMode = config.modelMode 24 | 25 | console.debug('mode', modelMode) 26 | 27 | const bingAIClient = new BingAIClient({ userToken: accessToken, features: { genImage: false } }) 28 | if (session.bingWeb_jailbreakConversationCache) 29 | bingAIClient.conversationsCache.set( 30 | session.bingWeb_jailbreakConversationId, 31 | session.bingWeb_jailbreakConversationCache, 32 | ) 33 | 34 | let answer = '' 35 | const response = await bingAIClient 36 | .sendMessage(question, { 37 | abortController: controller, 38 | toneStyle: modelMode, 39 | jailbreakConversationId: sydneyMode, 40 | onProgress: (message) => { 41 | answer = message 42 | // reference markers [^number^] 43 | answer = answer.replaceAll(/\[\^(\d+)\^\]/g, '$1') 44 | port.postMessage({ answer: answer, done: false, session: null }) 45 | }, 46 | ...(session.bingWeb_conversationId 47 | ? { 48 | conversationId: session.bingWeb_conversationId, 49 | encryptedConversationSignature: session.bingWeb_encryptedConversationSignature, 50 | clientId: session.bingWeb_clientId, 51 | invocationId: session.bingWeb_invocationId, 52 | } 53 | : session.bingWeb_jailbreakConversationId 54 | ? { 55 | jailbreakConversationId: session.bingWeb_jailbreakConversationId, 56 | parentMessageId: session.bingWeb_parentMessageId, 57 | } 58 | : {}), 59 | }) 60 | .catch((err) => { 61 | port.onMessage.removeListener(messageListener) 62 | port.onDisconnect.removeListener(disconnectListener) 63 | throw err 64 | }) 65 | 66 | if (!sydneyMode) { 67 | session.bingWeb_encryptedConversationSignature = response.encryptedConversationSignature 68 | session.bingWeb_conversationId = response.conversationId 69 | session.bingWeb_clientId = response.clientId 70 | session.bingWeb_invocationId = response.invocationId 71 | } else { 72 | session.bingWeb_jailbreakConversationId = response.jailbreakConversationId 73 | session.bingWeb_parentMessageId = response.messageId 74 | session.bingWeb_jailbreakConversationCache = bingAIClient.conversationsCache.get( 75 | response.jailbreakConversationId, 76 | ) 77 | } 78 | 79 | if (response.details.sourceAttributions && response.details.sourceAttributions.length > 0) { 80 | const footnotes = 81 | '\n\\-\n' + 82 | response.details.sourceAttributions 83 | .map((attr, index) => `\\[${index + 1}]: [${attr.providerDisplayName}](${attr.seeMoreUrl})`) 84 | .join('\n') 85 | answer += footnotes 86 | } 87 | 88 | pushRecord(session, question, answer) 89 | console.debug('conversation history', { content: session.conversationRecords }) 90 | port.onMessage.removeListener(messageListener) 91 | port.onDisconnect.removeListener(disconnectListener) 92 | port.postMessage({ answer: answer, done: true, session: session }) 93 | } 94 | -------------------------------------------------------------------------------- /src/services/apis/custom-api.mjs: -------------------------------------------------------------------------------- 1 | // custom api version 2 | 3 | // There is a lot of duplicated code here, but it is very easy to refactor. 4 | // The current state is mainly convenient for making targeted changes at any time, 5 | // and it has not yet had a negative impact on maintenance. 6 | // If necessary, I will refactor. 7 | 8 | import { getUserConfig } from '../../config/index.mjs' 9 | import { fetchSSE } from '../../utils/fetch-sse.mjs' 10 | import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' 11 | import { isEmpty } from 'lodash-es' 12 | import { pushRecord, setAbortController } from './shared.mjs' 13 | 14 | /** 15 | * @param {Browser.Runtime.Port} port 16 | * @param {string} question 17 | * @param {Session} session 18 | * @param {string} apiUrl 19 | * @param {string} apiKey 20 | * @param {string} modelName 21 | */ 22 | export async function generateAnswersWithCustomApi( 23 | port, 24 | question, 25 | session, 26 | apiUrl, 27 | apiKey, 28 | modelName, 29 | ) { 30 | const { controller, messageListener, disconnectListener } = setAbortController(port) 31 | 32 | const config = await getUserConfig() 33 | const prompt = getConversationPairs( 34 | session.conversationRecords.slice(-config.maxConversationContextLength), 35 | false, 36 | ) 37 | prompt.push({ role: 'user', content: question }) 38 | 39 | let answer = '' 40 | let finished = false 41 | const finish = () => { 42 | finished = true 43 | pushRecord(session, question, answer) 44 | console.debug('conversation history', { content: session.conversationRecords }) 45 | port.postMessage({ answer: null, done: true, session: session }) 46 | } 47 | await fetchSSE(apiUrl, { 48 | method: 'POST', 49 | signal: controller.signal, 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | Authorization: `Bearer ${apiKey}`, 53 | }, 54 | body: JSON.stringify({ 55 | messages: prompt, 56 | model: modelName, 57 | stream: true, 58 | max_tokens: config.maxResponseTokenLength, 59 | temperature: config.temperature, 60 | }), 61 | onMessage(message) { 62 | console.debug('sse message', message) 63 | if (finished) return 64 | if (message.trim() === '[DONE]') { 65 | finish() 66 | return 67 | } 68 | let data 69 | try { 70 | data = JSON.parse(message) 71 | } catch (error) { 72 | console.debug('json error', error) 73 | return 74 | } 75 | 76 | if (data.response) answer = data.response 77 | else { 78 | const delta = data.choices[0]?.delta?.content 79 | const content = data.choices[0]?.message?.content 80 | const text = data.choices[0]?.text 81 | if (delta !== undefined) { 82 | answer += delta 83 | } else if (content) { 84 | answer = content 85 | } else if (text) { 86 | answer += text 87 | } 88 | } 89 | port.postMessage({ answer: answer, done: false, session: null }) 90 | 91 | if (data.choices[0]?.finish_reason) { 92 | finish() 93 | return 94 | } 95 | }, 96 | async onStart() {}, 97 | async onEnd() { 98 | port.postMessage({ done: true }) 99 | port.onMessage.removeListener(messageListener) 100 | port.onDisconnect.removeListener(disconnectListener) 101 | }, 102 | async onError(resp) { 103 | port.onMessage.removeListener(messageListener) 104 | port.onDisconnect.removeListener(disconnectListener) 105 | if (resp instanceof Error) throw resp 106 | const error = await resp.json().catch(() => ({})) 107 | throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) 108 | }, 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /src/components/InputBox/index.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { isFirefox, isMobile, isSafari, updateRefHeight } from '../../utils' 4 | import { useTranslation } from 'react-i18next' 5 | import { getUserConfig } from '../../config/index.mjs' 6 | 7 | export function InputBox({ onSubmit, enabled, postMessage, reverseResizeDir }) { 8 | const { t } = useTranslation() 9 | const [value, setValue] = useState('') 10 | const reverseDivRef = useRef(null) 11 | const inputRef = useRef(null) 12 | const resizedRef = useRef(false) 13 | const [internalReverseResizeDir, setInternalReverseResizeDir] = useState(reverseResizeDir) 14 | 15 | useEffect(() => { 16 | setInternalReverseResizeDir( 17 | !isSafari() && !isFirefox() && !isMobile() ? internalReverseResizeDir : false, 18 | ) 19 | }, []) 20 | 21 | const virtualInputRef = internalReverseResizeDir ? reverseDivRef : inputRef 22 | 23 | useEffect(() => { 24 | inputRef.current.focus() 25 | 26 | const onResizeY = () => { 27 | if (virtualInputRef.current.h !== virtualInputRef.current.offsetHeight) { 28 | virtualInputRef.current.h = virtualInputRef.current.offsetHeight 29 | if (!resizedRef.current) { 30 | resizedRef.current = true 31 | virtualInputRef.current.style.maxHeight = '' 32 | } 33 | } 34 | } 35 | virtualInputRef.current.h = virtualInputRef.current.offsetHeight 36 | virtualInputRef.current.addEventListener('mousemove', onResizeY) 37 | }, []) 38 | 39 | useEffect(() => { 40 | if (!resizedRef.current) { 41 | if (!internalReverseResizeDir) { 42 | updateRefHeight(inputRef) 43 | virtualInputRef.current.h = virtualInputRef.current.offsetHeight 44 | virtualInputRef.current.style.maxHeight = '160px' 45 | } 46 | } 47 | }) 48 | 49 | useEffect(() => { 50 | if (enabled) 51 | getUserConfig().then((config) => { 52 | if (config.focusAfterAnswer) inputRef.current.focus() 53 | }) 54 | }, [enabled]) 55 | 56 | const handleKeyDownOrClick = (e) => { 57 | e.stopPropagation() 58 | if (e.type === 'click' || (e.keyCode === 13 && e.shiftKey === false)) { 59 | e.preventDefault() 60 | if (enabled) { 61 | if (!value) return 62 | onSubmit(value) 63 | setValue('') 64 | } else { 65 | postMessage({ stop: true }) 66 | } 67 | } 68 | } 69 | 70 | return ( 71 |
72 |
83 |