feat: Independent Panel (#63, #120)

This commit is contained in:
josc146
2023-04-02 20:27:07 +08:00
parent 7f34dc81d4
commit 60b479729a
20 changed files with 531 additions and 38 deletions
+13 -2
View File
@@ -32,6 +32,10 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, callback) {
import: './src/popup/index.jsx',
dependOn: 'shared',
},
IndependentPanel: {
import: './src/pages/IndependentPanel/index.jsx',
dependOn: 'shared',
},
shared: [
'preact',
'webextension-polyfill',
@@ -238,14 +242,21 @@ async function copyFiles(entryPoints, targetDir) {
async function finishOutput(outputDirSuffix) {
const commonFiles = [
{ src: 'src/logo.png', dst: 'logo.png' },
{ src: 'build/shared.js', dst: 'shared.js' },
{ src: 'build/content-script.css', dst: 'content-script.css' }, // shared
{ src: 'build/content-script.js', dst: 'content-script.js' },
{ src: 'build/content-script.css', dst: 'content-script.css' },
{ src: 'build/background.js', dst: 'background.js' },
{ src: 'build/popup.js', dst: 'popup.js' },
{ src: 'build/popup.css', dst: 'popup.css' },
{ src: 'src/popup/index.html', dst: 'popup.html' },
{ src: 'src/logo.png', dst: 'logo.png' },
{ src: 'build/IndependentPanel.js', dst: 'IndependentPanel.js' },
{ src: 'src/pages/IndependentPanel/index.html', dst: 'IndependentPanel.html' },
]
// chromium
+6 -1
View File
@@ -87,5 +87,10 @@
"Clear Conversation": "Clear Conversation",
"Retry": "Retry",
"Exceeded maximum context length": "Exceeded maximum context length, please clear the conversation and try again",
"Regenerate the answer after switching model": "Regenerate the answer after switching model"
"Regenerate the answer after switching model": "Regenerate the answer after switching model",
"Pin": "Pin",
"Unpin": "Unpin",
"Delete Conversation": "Delete Conversation",
"Clear conversations": "Clear conversations",
"Settings": "Settings"
}
-3
View File
@@ -4,7 +4,4 @@ import { resources } from './resources'
i18n.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
})
+6 -1
View File
@@ -87,5 +87,10 @@
"Clear Conversation": "清理对话",
"Retry": "重试",
"Exceeded maximum context length": "超出最大上下文长度, 请清理对话并重试",
"Regenerate the answer after switching model": "快捷切换模型时自动重新生成回答"
"Regenerate the answer after switching model": "快捷切换模型时自动重新生成回答",
"Pin": "固定侧边",
"Unpin": "收缩侧边",
"Delete Conversation": "删除对话",
"Clear conversations": "清空记录",
"Settings": "设置"
}
+6 -1
View File
@@ -87,5 +87,10 @@
"Clear Conversation": "清理對話",
"Retry": "重試",
"Exceeded maximum context length": "超出最大上下文長度, 請清理對話並重試",
"Regenerate the answer after switching model": "快捷切換模型時自動重新生成回答"
"Regenerate the answer after switching model": "快捷切換模型時自動重新生成回答",
"Pin": "固定側邊",
"Unpin": "收縮側邊",
"Delete Conversation": "刪除對話",
"Clear conversations": "清空記錄",
"Settings": "設置"
}
+1 -1
View File
@@ -66,7 +66,7 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
deleteConversation(accessToken, session.conversationId)
if (session.autoClean) deleteConversation(accessToken, session.conversationId)
})
const models = await getModels(accessToken).catch(() => {
+54
View File
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
ConfirmButton.propTypes = {
onConfirm: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
}
function ConfirmButton({ onConfirm, text }) {
const { t } = useTranslation()
const [waitConfirm, setWaitConfirm] = useState(false)
const confirmRef = useRef(null)
useEffect(() => {
if (waitConfirm) confirmRef.current.focus()
}, [waitConfirm])
return (
<span>
<button
ref={confirmRef}
type="button"
className="normal-button"
style={{
...(waitConfirm ? {} : { display: 'none' }),
}}
onBlur={() => {
setWaitConfirm(false)
}}
onClick={() => {
setWaitConfirm(false)
onConfirm()
}}
>
{t('Confirm')}
</button>
<button
type="button"
className="normal-button"
style={{
...(waitConfirm ? { display: 'none' } : {}),
}}
onClick={() => {
setWaitConfirm(true)
}}
>
{text}
</button>
</span>
)
}
export default ConfirmButton
+11 -4
View File
@@ -65,8 +65,8 @@ function ConversationCard(props) {
const config = useConfig()
useEffect(() => {
if (props.onUpdate) props.onUpdate()
})
if (props.onUpdate) props.onUpdate(port, session, conversationItemData)
}, [session, conversationItemData])
useEffect(() => {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
@@ -201,6 +201,7 @@ function ConversationCard(props) {
title={t('Close the Window')}
size={16}
onClick={() => {
port.disconnect()
if (props.onClose) props.onClose()
}}
/>
@@ -217,7 +218,7 @@ function ConversationCard(props) {
<img src={logo} style="user-select:none;width:20px;height:20px;" />
)}
<select
style={{ width: windowSize[0] * 0.05 + 'px' }}
style={props.notClampSize ? {} : { width: windowSize[0] * 0.05 + 'px' }}
className="normal-button"
required
onChange={(e) => {
@@ -275,6 +276,7 @@ function ConversationCard(props) {
)}
<DeleteButton
size={16}
text={t('Clear Conversation')}
onConfirm={() => {
port.postMessage({ stop: true })
Browser.runtime.sendMessage({
@@ -309,7 +311,11 @@ function ConversationCard(props) {
<div
ref={bodyRef}
className="markdown-body"
style={{ maxHeight: windowSize[1] * 0.75 + 'px' }}
style={
props.notClampSize
? { flexGrow: 1 }
: { maxHeight: windowSize[1] * 0.75 + 'px', resize: 'vertical' }
}
>
{conversationItemData.map((data, idx) => (
<ConversationItem
@@ -356,6 +362,7 @@ ConversationCard.propTypes = {
onClose: PropTypes.func,
dockable: PropTypes.bool,
onDock: PropTypes.func,
notClampSize: PropTypes.bool,
}
export default memo(ConversationCard)
+12 -12
View File
@@ -80,11 +80,10 @@ function DecisionCard(props) {
return <ConversationCard session={props.session} question={question} />
}
return (
<p
className="gpt-inner manual-btn icon-and-text"
onClick={() => setTriggered(true)}
>
<SearchIcon size="small" /> {t('Ask ChatGPT')}
<p className="gpt-inner manual-btn" onClick={() => setTriggered(true)}>
<span className="icon-and-text">
<SearchIcon size="small" /> {t('Ask ChatGPT')}
</span>
</p>
)
case 'questionMark':
@@ -95,18 +94,19 @@ function DecisionCard(props) {
return <ConversationCard session={props.session} question={question} />
}
return (
<p
className="gpt-inner manual-btn icon-and-text"
onClick={() => setTriggered(true)}
>
<SearchIcon size="small" /> {t('Ask ChatGPT')}
<p className="gpt-inner manual-btn" onClick={() => setTriggered(true)}>
<span className="icon-and-text">
<SearchIcon size="small" /> {t('Ask ChatGPT')}
</span>
</p>
)
}
else
return (
<p className="gpt-inner icon-and-text">
<LightBulbIcon size="small" /> {t('No Input Found')}
<p className="gpt-inner">
<span className="icon-and-text">
<LightBulbIcon size="small" /> {t('No Input Found')}
</span>
</p>
)
})()}
+3 -2
View File
@@ -6,9 +6,10 @@ import { TrashIcon } from '@primer/octicons-react'
DeleteButton.propTypes = {
onConfirm: PropTypes.func.isRequired,
size: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
}
function DeleteButton({ onConfirm, size }) {
function DeleteButton({ onConfirm, size, text }) {
const { t } = useTranslation()
const [waitConfirm, setWaitConfirm] = useState(false)
const confirmRef = useRef(null)
@@ -38,7 +39,7 @@ function DeleteButton({ onConfirm, size }) {
{t('Confirm')}
</button>
<span
title={t('Clear Conversation')}
title={text}
className="gpt-util-icon"
style={waitConfirm ? { display: 'none' } : {}}
onClick={() => {
+7 -6
View File
@@ -139,18 +139,19 @@ export function getNavigatorLanguage() {
return navigator.language.substring(0, 2)
}
export function isUsingApiKey(config) {
export function isUsingApiKey(configOrSession) {
return (
gptApiModelKeys.includes(config.modelName) || chatgptApiModelKeys.includes(config.modelName)
gptApiModelKeys.includes(configOrSession.modelName) ||
chatgptApiModelKeys.includes(configOrSession.modelName)
)
}
export function isUsingMultiModeModel(config) {
return bingWebModelKeys.includes(config.modelName)
export function isUsingMultiModeModel(configOrSession) {
return bingWebModelKeys.includes(configOrSession.modelName)
}
export function isUsingCustomModel(config) {
return customApiModelKeys.includes(config.modelName)
export function isUsingCustomModel(configOrSession) {
return customApiModelKeys.includes(configOrSession.modelName)
}
export async function getPreferredLanguageKey() {
+61
View File
@@ -0,0 +1,61 @@
import Browser from 'webextension-polyfill'
import { initSession } from '../utils/index.mjs'
import { getUserConfig } from './index.mjs'
export const initDefaultSession = async () => {
const config = await getUserConfig()
const modelName = config.modelName
return initSession({
sessionName: new Date().toLocaleString(),
modelName: modelName,
autoClean: false,
})
}
export const createSession = async (session) => {
const currentSessions = await getSessions()
if (!session) session = await initDefaultSession()
currentSessions.unshift(session)
await Browser.storage.local.set({ sessions: currentSessions })
return { session, currentSessions }
}
export const deleteSession = async (sessionId) => {
const currentSessions = await getSessions()
const index = currentSessions.findIndex((session) => session.sessionId === sessionId)
currentSessions.splice(index, 1)
if (currentSessions.length > 0) {
await Browser.storage.local.set({ sessions: currentSessions })
return currentSessions
}
return await resetSessions()
}
export const getSession = async (sessionId) => {
const currentSessions = await getSessions()
return {
session: currentSessions.find((session) => session.sessionId === sessionId),
currentSessions,
}
}
export const updateSession = async (newSession) => {
const currentSessions = await getSessions()
currentSessions[
currentSessions.findIndex((session) => session.sessionId === newSession.sessionId)
] = newSession
await Browser.storage.local.set({ sessions: currentSessions })
return currentSessions
}
export const resetSessions = async () => {
const currentSessions = [await initDefaultSession()]
await Browser.storage.local.set({ sessions: currentSessions })
return currentSessions
}
export const getSessions = async () => {
const { sessions } = await Browser.storage.local.get('sessions')
if (sessions && sessions.length > 0) return sessions
return await resetSessions()
}
+5 -1
View File
@@ -1,4 +1,5 @@
@import '../fonts/styles.css';
@import '../pages/styles.scss';
[data-theme='auto'] {
@import 'github-markdown-css/github-markdown.css';
@@ -54,6 +55,9 @@
margin-bottom: 20px;
.gpt-inner {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid;
overflow: hidden;
@@ -73,7 +77,6 @@
padding: 5px 15px 10px;
background-color: var(--theme-color);
color: var(--font-color);
resize: vertical;
overflow-y: auto;
ul,
@@ -206,6 +209,7 @@
background-color: var(--theme-color);
color: var(--font-color);
resize: none;
min-height: 70px;
max-height: 240px;
}
+2
View File
@@ -12,6 +12,8 @@ export function useConfig(initFn) {
}, [])
useEffect(() => {
const listener = (changes) => {
if (Object.keys(changes).length === 1 && 'sessions' in changes) return
const changedItems = Object.keys(changes)
let newConfig = {}
for (const key of changedItems) {
+156
View File
@@ -0,0 +1,156 @@
import {
createSession,
resetSessions,
getSessions,
updateSession,
getSession,
deleteSession,
} from '../../config/localSession'
import { useEffect, useRef, useState } from 'react'
import './styles.scss'
import { useConfig } from '../../hooks/use-config.mjs'
import { useTranslation } from 'react-i18next'
import ConfirmButton from '../../components/ConfirmButton'
import ConversationCard from '../../components/ConversationCard'
import DeleteButton from '../../components/DeleteButton'
function App() {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(false)
const config = useConfig()
const [sessions, setSessions] = useState([])
const [sessionId, setSessionId] = useState(null)
const [currentSession, setCurrentSession] = useState(null)
const [renderContent, setRenderContent] = useState(false)
const currentPort = useRef(null)
const setSessionIdSafe = async (sessionId) => {
if (currentPort.current) {
try {
currentPort.current.postMessage({ stop: true })
currentPort.current.disconnect()
} catch (e) {
/* empty */
}
currentPort.current = null
}
const { session, currentSessions } = await getSession(sessionId)
if (session) setSessionId(sessionId)
else if (currentSessions.length > 0) setSessionId(currentSessions[0].sessionId)
}
useEffect(() => {
document.documentElement.dataset.theme = config.themeMode
}, [config.themeMode])
useEffect(() => {
// eslint-disable-next-line
;(async () => {
const sessions = await getSessions()
setSessions(sessions)
await setSessionIdSafe(sessions[0].sessionId)
})()
}, [])
useEffect(() => {
// eslint-disable-next-line
;(async () => {
if (sessions.length > 0) {
setCurrentSession((await getSession(sessionId)).session)
setRenderContent(false)
setTimeout(() => {
setRenderContent(true)
})
}
})()
}, [sessionId])
const toggleSidebar = () => {
setCollapsed(!collapsed)
}
const createNewChat = async () => {
const { session, currentSessions } = await createSession()
setSessions(currentSessions)
await setSessionIdSafe(session.sessionId)
}
const clearConversations = async () => {
const sessions = await resetSessions()
setSessions(sessions)
await setSessionIdSafe(sessions[0].sessionId)
}
return (
<div className="IndependentPanel">
<div className="chat-container">
<div className={`chat-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="chat-sidebar-button-group">
<button className="normal-button" onClick={toggleSidebar}>
{collapsed ? t('Pin') : t('Unpin')}
</button>
<button className="normal-button" onClick={createNewChat}>
{t('New Chat')}
</button>
</div>
<hr />
<div className="chat-list">
{sessions.map(
(
session,
index, // TODO editable session name
) => (
<button
key={index}
className={`normal-button ${sessionId === session.sessionId ? 'active' : ''}`}
style="display: flex; align-items: center; justify-content: space-between;"
onClick={() => {
setSessionIdSafe(session.sessionId)
}}
>
{session.sessionName}
<span className="gpt-util-group">
<DeleteButton
size={14}
text={t('Delete Conversation')}
onConfirm={() =>
deleteSession(session.sessionId).then((sessions) => {
setSessions(sessions)
setSessionIdSafe(sessions[0].sessionId)
})
}
/>
</span>
</button>
),
)}
</div>
<hr />
<div className="chat-sidebar-button-group">
<ConfirmButton text={t('Clear conversations')} onConfirm={clearConversations} />
<button className="normal-button">{t('Settings')}</button>
</div>
</div>
<div className="chat-content">
{renderContent && currentSession && currentSession.conversationRecords && (
<div className="chatgptbox-container" style="height:100%;">
<ConversationCard
session={currentSession}
notClampSize={true}
onUpdate={(port, session, cData) => {
currentPort.current = port
if (cData.length > 0 && cData[cData.length - 1].done) {
updateSession(session).then(setSessions)
setCurrentSession(session)
}
}}
/>
</div>
)}
</div>
</div>
</div>
)
}
export default App
+13
View File
@@ -0,0 +1,13 @@
<html>
<head>
<title>ChatGPTBox</title>
<link rel="stylesheet" href="content-script.css" />
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<script src="shared.js"></script>
<script src="IndependentPanel.js"></script>
<script src="content-script.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
import { render } from 'preact'
import '../../_locales/i18n-react'
import App from './App'
import Browser from 'webextension-polyfill'
import { changeLanguage } from 'i18next'
import { getPreferredLanguageKey } from '../../config/index.mjs'
document.body.style.margin = 0
document.body.style.overflow = 'hidden'
getPreferredLanguageKey().then((lang) => {
changeLanguage(lang)
})
Browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'CHANGE_LANG') {
const data = message.data
changeLanguage(data.lang)
}
})
render(<App />, document.getElementById('app'))
+131
View File
@@ -0,0 +1,131 @@
[data-theme='auto'] {
@media screen and (prefers-color-scheme: dark) {
--font-color: #c9d1d9;
--font-active-color: #ffffff;
--theme-color: #202124;
--theme-border-color: #3c4043;
--active-color: #3c4043;
}
@media screen and (prefers-color-scheme: light) {
--font-color: #24292f;
--font-active-color: #cc3333;
--theme-color: #ffffff;
--theme-border-color: #aeafb2;
--active-color: #d0d4da;
}
}
[data-theme='dark'] {
--font-color: #c9d1d9;
--font-active-color: #ffffff;
--theme-color: #202124;
--theme-border-color: #3c4043;
--active-color: #3c4043;
}
[data-theme='light'] {
--font-color: #24292f;
--font-active-color: #cc3333;
--theme-color: #ffffff;
--theme-border-color: #aeafb2;
--active-color: #d0d4da;
}
.IndependentPanel * {
font-family: 'Cairo', sans-serif;
font-size: 14px;
}
.IndependentPanel {
.chat-container {
display: flex;
width: 100%;
height: 100%;
}
.chat-sidebar {
display: flex;
flex-direction: column;
width: 250px;
background-color: var(--theme-color);
transition: width 0.3s;
padding: 10px;
}
.chat-sidebar.collapsed {
width: 60px;
}
.chat-sidebar:hover,
.chat-sidebar:not(.collapsed) {
width: 250px;
}
.chat-sidebar-button-group {
display: flex;
flex-direction: column;
padding: 0;
background-color: var(--theme-color);
gap: 15px;
}
.chat-list {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 0;
background-color: var(--theme-color);
overflow-y: auto;
overflow-x: hidden;
gap: 15px;
}
.chat-content {
flex-grow: 1;
border: 1px solid var(--theme-border-color);
background-color: var(--theme-color);
}
.normal-button {
width: 100%;
min-height: 40px;
padding: 1px 6px;
border: 1px solid;
border-color: var(--theme-border-color);
background-color: var(--theme-color);
color: var(--font-color);
border-radius: 5px;
cursor: pointer;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.gpt-util-group {
display: flex;
gap: 15px;
align-items: center;
}
.gpt-util-icon {
display: flex;
cursor: pointer;
align-items: center;
color: var(--font-color);
}
.gpt-util-icon:hover {
color: var(--font-active-color);
}
.normal-button.active,
.normal-button:hover {
background-color: var(--active-color);
}
hr {
height: 1px;
background-color: var(--theme-border-color);
border: none;
margin: 15px 0;
}
}
+1
View File
@@ -0,0 +1 @@
@import 'IndependentPanel/styles.scss';
+24 -4
View File
@@ -1,3 +1,5 @@
import { Models } from '../config/index.mjs'
/**
* @typedef {object} BingWeb
* @property {string|null} conversationSignature
@@ -9,9 +11,12 @@
* @typedef {object} Session
* @property {string|null} question
* @property {Object[]|null} conversationRecords
* @property {boolean} isRetry
* @property {string|null} sessionName
* @property {string|null} sessionId
* @property {string|null} aiName
* @property {string|null} modelName
* @property {boolean|null} autoClean
* @property {boolean} isRetry
* @property {string|null} conversationId - chatGPT web mode
* @property {string|null} messageId - chatGPT web mode
* @property {string|null} parentMessageId - chatGPT web mode
@@ -20,16 +25,31 @@
/**
* @param {string|null} question
* @param {Object[]|null} conversationRecords
* @param {string|null} sessionName
* @param {string|null} modelName
* @param {boolean|null} autoClean
* @returns {Session}
*/
export function initSession({ question = null, conversationRecords = [] } = {}) {
export function initSession({
question = null,
conversationRecords = [],
sessionName = null,
modelName = null,
autoClean = true,
} = {}) {
return {
// common
question,
conversationRecords,
sessionName,
sessionId: crypto.randomUUID(),
aiName: modelName ? Models[modelName].desc : null,
modelName,
autoClean,
isRetry: false,
aiName: null,
modelName: null,
// chatgpt-web
conversationId: null,