diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..20d225aa --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,39 @@ +module.exports = { + settings: { + react: { version: 'detect' }, + }, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + ], + overrides: [ + { + env: { + node: true, + }, + files: ['.eslintrc.{js,cjs}'], + parserOptions: { + sourceType: 'script', + }, + }, + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'react'], + rules: { + // '@typescript-eslint/no-this-alias': 'warn', + // '@typescript-eslint/no-unused-vars': 'warn', + // 'no-useless-escape': 'warn', + 'react/display-name': 'off', + }, +}; diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 00000000..a86e10e8 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,38 @@ +name: ESLint + +on: + push: + branches: ['main'] + pull_request: + # The branches below must be a subset of the branches above + branches: ['main'] + +jobs: + eslint: + name: Run eslint scanning + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install ESLint + run: | + npm install eslint@8.10.0 + npm install @microsoft/eslint-formatter-sarif@2.1.7 + + - name: Run ESLint + run: npx eslint . + --config .eslintrc.cjs + --format @microsoft/eslint-formatter-sarif + --output-file eslint-results.sarif + continue-on-error: true + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: eslint-results.sarif + wait-for-processing: true diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 60e3573f..010ea96a 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -8,6 +8,9 @@ on: jobs: prettier: + permissions: + contents: write + actions: write runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 10b2f7af..ddba047c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,9 +5,6 @@ on: branches: [main] workflow_dispatch: -env: - VERSION: 2.0.6 - permissions: contents: write id-token: write @@ -26,6 +23,12 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Get version from package.json + id: packageJson + uses: RadovanPelka/github-action-json@main + with: + path: 'package.json' + - name: Use Node.js 18 uses: actions/setup-node@v3 with: @@ -97,8 +100,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release-Linux/koala-client-${{VERSION}}-linux-x86_64.AppImage - asset_name: KoalaClient-${{VERSION}}-linux-x86_64.AppImage + asset_path: ./release-Linux/koala-client-${{steps.packageJson.outputs.version}}-linux-x86_64.AppImage + asset_name: KoalaClient-${{steps.packageJson.outputs.version}}-linux-x86_64.AppImage asset_content_type: application - name: Upload Release Asset - MacOS @@ -107,8 +110,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release-macOS/koala-client-${{VERSION}}-mac-x64.dmg - asset_name: KoalaClient-${{VERSION}}-mac-x64.dmg + asset_path: ./release-macOS/koala-client-${{steps.packageJson.outputs.version}}-mac-x64.dmg + asset_name: KoalaClient-${{steps.packageJson.outputs.version}}-mac-x64.dmg asset_content_type: application - name: Upload Release Asset - Windows @@ -117,8 +120,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release-Windows/koala-client-${{VERSION}}-win-x64.exe - asset_name: KoalaClient-${{VERSION}}-win-x64.exe + asset_path: ./release-Windows/koala-client-${{steps.packageJson.outputs.version}}-win-x64.exe + asset_name: KoalaClient-${{steps.packageJson.outputs.version}}-win-x64.exe asset_content_type: application - name: Zip Unpacked Release - Windows @@ -134,7 +137,7 @@ jobs: with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./release-Windows/win-unpacked.zip - asset_name: KoalaClient-${{VERSION}}-win-x64-portable.zip + asset_name: KoalaClient-${{steps.packageJson.outputs.version}}-win-x64-portable.zip asset_content_type: zip - name: Zip Hash Info diff --git a/package.json b/package.json index 3a60a445..9930e444 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "koala-client", "private": true, - "version": "2.0.6", + "version": "2.0.7", "type": "module", "homepage": "./", "main": "electron/index.cjs", @@ -14,6 +14,8 @@ "electron": "concurrently -k \"BROWSER=none yarn dev\" \"wait-on tcp:5173 && electron .\"", "pack": "yarn build && electron-builder --dir", "make": "yarn build && electron-builder", + "lint": "eslint ./src", + "errors": "eslint ./src --quiet", "format": "prettier --write ./" }, "build": { @@ -81,11 +83,15 @@ "@types/react-dom": "^18.0.10", "@types/react-scroll-to-bottom": "^4.2.0", "@types/uuid": "^9.0.1", + "@typescript-eslint/eslint-plugin": "^6.17.0", + "@typescript-eslint/parser": "^6.17.0", "@vitejs/plugin-react-swc": "^3.0.0", "autoprefixer": "^10.4.13", "concurrently": "^8.0.1", "electron": "^28.1.1", "electron-builder": "^23.6.0", + "eslint": "^8.56.0", + "eslint-plugin-react": "^7.33.2", "postcss": "^8.4.21", "tailwindcss": "^3.2.7", "typescript": "^4.9.3", diff --git a/src/App.tsx b/src/App.tsx index 900b4a88..c8dd572c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import useStore from '@store/store'; import i18n from './i18n'; @@ -6,33 +6,151 @@ import Chat from '@components/Chat'; import Menu from '@components/Menu'; import useAddChat from '@hooks/useAddChat'; +import useGoBack from '@hooks/useGoBack'; +import useGoForward from '@hooks/useGoForward'; +import useCopyCodeBlock from '@hooks/useCopyCodeBlock'; import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; +import useSubmit from '@hooks/useSubmit'; import { ChatInterface } from '@type/chat'; import { Theme } from '@type/theme'; import ApiPopup from '@components/ApiPopup'; import Toast from '@components/Toast'; import isElectron from '@utils/electron'; +import GlobalContext from '@hooks/GlobalContext'; function App() { const initialiseNewChat = useInitialiseNewChat(); const setChats = useStore((state) => state.setChats); const setTheme = useStore((state) => state.setTheme); const setApiKey = useStore((state) => state.setApiKey); + const currentChatIndex = useStore((state) => state.currentChatIndex); const setCurrentChatIndex = useStore((state) => state.setCurrentChatIndex); const setHideSideMenu = useStore((state) => state.setHideSideMenu); const hideSideMenu = useStore((state) => state.hideSideMenu); const addChat = useAddChat(); + const goBack = useGoBack(); + const goForward = useGoForward(); + const copyCodeBlock = useCopyCodeBlock(); + const { handleSubmit } = useSubmit(); + + const [sharedTextareaRef, setSharedTextareaRef] = + useState | null>(null); + + const setRef = (newRef: React.RefObject | null) => { + setSharedTextareaRef(newRef); + }; + + const handleGenerate = () => { + if (useStore.getState().generating) return; + const updatedChats: ChatInterface[] = JSON.parse( + JSON.stringify(useStore.getState().chats) + ); + const content = sharedTextareaRef?.current?.value; + const updatedMessages = updatedChats[currentChatIndex].messages; + if (!content) { + return; + } + + updatedMessages.push({ role: 'user', content: content }); + if (sharedTextareaRef && sharedTextareaRef.current) { + sharedTextareaRef.current.value = ''; + } + + setChats(updatedChats); + handleSubmit(); + }; + + const pasteSubmit = async () => { + try { + if (useStore.getState().generating) return; + const updatedChats: ChatInterface[] = JSON.parse( + JSON.stringify(useStore.getState().chats) + ); + const updatedMessages = updatedChats[currentChatIndex].messages; + + const text = await navigator.clipboard.readText(); + if (!text) { + return; + } + + updatedMessages.push({ role: 'user', content: text }); + if (sharedTextareaRef && sharedTextareaRef.current) { + sharedTextareaRef.current.value = ''; + } + + setChats(updatedChats); + handleSubmit(); + } catch (err) { + console.error('Failed to read clipboard contents:', err); + } + }; const handleKeyDown = (e: React.KeyboardEvent) => { - // put any general app-wide keybinds here + // Put any general app-wide keybinds here: + + // ctrl+e - Toggle side menu if (e.ctrlKey && e.key === 'e') { e.preventDefault(); setHideSideMenu(!hideSideMenu); } + // ctrl+n - New chat if (e.ctrlKey && e.key === 'n') { e.preventDefault(); addChat(); + sharedTextareaRef?.current?.focus(); + } + + // ctrl+o - Copy code block + if (e.ctrlKey && e.key === 'o') { + e.preventDefault(); + copyCodeBlock(); + } + + // ctrl+g - Focus textarea + if (e.ctrlKey && e.key === 'g') { + e.preventDefault(); + sharedTextareaRef?.current?.focus(); + } + + // ctrl+s - Save bottom message + generate + if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + handleGenerate(); + } + + // ctrl+p - New chat from clipboard (insta-generate) + if (e.ctrlKey && e.key === 'p') { + e.preventDefault(); + console.log('test'); + addChat(); + pasteSubmit(); + } + + // ctrl+left - Previous chat + if (e.ctrlKey && e.key === 'ArrowLeft') { + e.preventDefault(); + goBack(); + } + + // ctrl+left - Next chat + if (e.ctrlKey && e.key === 'ArrowRight') { + e.preventDefault(); + goForward(); + } + }; + + const handleMouseUp = (e: React.MouseEvent) => { + // Mouse button 3 (back) + if (e.button === 3) { + e.preventDefault(); + goBack(); + } + + // Mouse button 4 (forward) + if (e.button === 4) { + e.preventDefault(); + goForward(); } }; @@ -96,17 +214,34 @@ function App() { } }, []); + useEffect(() => { + const handleGlobalMouseUp = (e: MouseEvent) => { + if (e.button === 3 || e.button === 4) { + handleMouseUp(e as unknown as React.MouseEvent); + } + }; + + document.addEventListener('mouseup', handleGlobalMouseUp); + + return () => { + document.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, []); + return ( -
- - - - -
+ +
+ + + + +
+
); } diff --git a/src/api/api.ts b/src/api/api.ts index e6b367ed..09421ee7 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,5 +1,3 @@ -import { modelMaxToken } from '@constants/chat'; -import countTokens from '@utils/messageUtils'; import { ShareGPTSubmitBodyInterface } from '@type/api'; import { ConfigInterface, MessageInterface } from '@type/chat'; import { isAzureEndpoint, uuidv4 } from '@utils/api'; @@ -41,21 +39,22 @@ export const getChatCompletion = async ( } } - const { max_context, ...restConfig } = config; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + delete config.max_context; if (isTitleGen) { - restConfig.model = 'gpt-3.5-turbo'; + config.model = 'gpt-3.5-turbo'; } // todo: option in config - restConfig.user = uuidv4(); + config.user = uuidv4(); const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ messages, - ...restConfig, + ...config, }), }); if (!response.ok) throw new Error(await response.text()); @@ -99,17 +98,17 @@ export const getChatCompletionStream = async ( } } - const { max_context, ...restConfig } = config; + delete config.max_context; // todo: option in config - restConfig.user = uuidv4(); + config.user = uuidv4(); const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ messages, - ...restConfig, + ...config, stream: true, }), }); diff --git a/src/components/ApiMenu/ApiMenu.tsx b/src/components/ApiMenu/ApiMenu.tsx index 62ad6189..1f3ee3fc 100644 --- a/src/components/ApiMenu/ApiMenu.tsx +++ b/src/components/ApiMenu/ApiMenu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import useStore from '@store/store'; @@ -99,9 +99,11 @@ const ApiMenu = ({ ns='api' components={[ , ]} /> diff --git a/src/components/ApiPopup/ApiPopup.tsx b/src/components/ApiPopup/ApiPopup.tsx index 2b7d7be4..3c310752 100644 --- a/src/components/ApiPopup/ApiPopup.tsx +++ b/src/components/ApiPopup/ApiPopup.tsx @@ -61,9 +61,11 @@ const ApiPopup = () => { ns='api' components={[ , ]} /> @@ -74,6 +76,7 @@ const ApiPopup = () => { ns='api' components={[ { setIsModalOpen(false); diff --git a/src/components/Chat/ChatContent/ChatContent.tsx b/src/components/Chat/ChatContent/ChatContent.tsx index c244f564..006b053d 100644 --- a/src/components/Chat/ChatContent/ChatContent.tsx +++ b/src/components/Chat/ChatContent/ChatContent.tsx @@ -1,17 +1,12 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import ScrollToBottom from 'react-scroll-to-bottom'; import useStore from '@store/store'; -import ScrollToBottomButton from './ScrollToBottomButton'; -import ModelConfigBar from './ModelConfigBar'; import Message from './Message'; import NewMessageButton from './Message/NewMessageButton'; import CrossIcon from '@icon/CrossIcon'; import useSubmit from '@hooks/useSubmit'; -import DownloadChat from './DownloadChat'; -import CloneChat from './CloneChat'; -import ShareGPT from '@components/ShareGPT'; const ChatContent = () => { const inputRole = useStore((state) => state.inputRole); @@ -24,16 +19,10 @@ const ChatContent = () => { ? state.chats[state.currentChatIndex].messages : [] ); - const stickyIndex = useStore((state) => - state.chats && - state.chats.length > 0 && - state.currentChatIndex >= 0 && - state.currentChatIndex < state.chats.length - ? state.chats[state.currentChatIndex].messages.length - : 0 - ); const generating = useStore.getState().generating; - const hideSideMenu = useStore((state) => state.hideSideMenu); + const [editingMessageIndex, setEditingMessageIndex] = useState( + null + ); const saveRef = useRef(null); @@ -61,6 +50,9 @@ const ChatContent = () => { role={message.role} content={message.content} messageIndex={index} + isBottomChat={false} + editingMessageIndex={editingMessageIndex} + setEditingMessageIndex={setEditingMessageIndex} /> {!generating && } @@ -70,8 +62,10 @@ const ChatContent = () => { {error !== '' && (
diff --git a/src/components/Chat/ChatContent/DownloadChat.tsx b/src/components/Chat/ChatContent/DownloadChat.tsx deleted file mode 100644 index 627a7348..00000000 --- a/src/components/Chat/ChatContent/DownloadChat.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import useStore from '@store/store'; -import PopupModal from '@components/PopupModal'; -import { - chatToMarkdown, - downloadImg, - downloadMarkdown, - // downloadPDF, - htmlToImg, -} from '@utils/chat'; -import ImageIcon from '@icon/ImageIcon'; -import PdfIcon from '@icon/PdfIcon'; -import MarkdownIcon from '@icon/MarkdownIcon'; -import JsonIcon from '@icon/JsonIcon'; - -import downloadFile from '@utils/downloadFile'; - -const DownloadChat = React.memo( - ({ saveRef }: { saveRef: React.RefObject }) => { - const { t } = useTranslation(); - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - - {isModalOpen && ( - -
- - -
-
- )} - - ); - } -); - -export default DownloadChat; diff --git a/src/components/Chat/ChatContent/Message/CodeBlock/CodeBlock.tsx b/src/components/Chat/ChatContent/Message/CodeBlock/CodeBlock.tsx index 51cb0aaa..c5db1275 100644 --- a/src/components/Chat/ChatContent/Message/CodeBlock/CodeBlock.tsx +++ b/src/components/Chat/ChatContent/Message/CodeBlock/CodeBlock.tsx @@ -1,7 +1,5 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef } from 'react'; -import CopyIcon from '@icon/CopyIcon'; -import TickIcon from '@icon/TickIcon'; import CodeBar from './CodeBar'; const CodeBlock = ({ diff --git a/src/components/Chat/ChatContent/Message/CodeBlock/MermaidBlock.tsx b/src/components/Chat/ChatContent/Message/CodeBlock/MermaidBlock.tsx index 54603ad9..e3fbfa46 100644 --- a/src/components/Chat/ChatContent/Message/CodeBlock/MermaidBlock.tsx +++ b/src/components/Chat/ChatContent/Message/CodeBlock/MermaidBlock.tsx @@ -31,8 +31,6 @@ const MermaidBlock = ({ const isGenerating = useStore.getState().generating; const [isPannable, setIsPannable] = useState(false); - const lang = 'mermaid'; - useEffect(() => { if (mermaidContainerRef.current) { Mermaid.mermaidAPI.initialize({ @@ -42,8 +40,6 @@ const MermaidBlock = ({ logLevel: 3, arrowMarkerAbsolute: false, fontSize: forcedFontSize, - - //htmlLabels: false }); Mermaid.mermaidAPI @@ -76,7 +72,7 @@ const MermaidBlock = ({ setIsPannable(true); } }); - } catch (err: any) { + } catch (err) { if (!mermaidContainerRef.current) throw err; mermaidContainerRef.current.innerHTML = chartDefinition ?? ''; // error rendering (fallback to raw) throw err; diff --git a/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx b/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx index 8343a032..391e9944 100644 --- a/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx +++ b/src/components/Chat/ChatContent/Message/CommandPrompt/CommandPrompt.tsx @@ -81,14 +81,14 @@ const CommandPrompt = ({ className='px-4 py-2 hover:bg-neutral-dark cursor-pointer text-start w-full' onClick={() => { _setContent((prev) => { - let startContent = prev.slice(0, cursorPosition); - let endContent = prev.slice(cursorPosition); + const startContent = prev.slice(0, cursorPosition); + const endContent = prev.slice(cursorPosition); - let paddedStart = + const paddedStart = !startContent.endsWith('\n') && startContent.length > 0 ? '\n' : ''; - let paddedEnd = + const paddedEnd = !endContent.startsWith('\n') && endContent.length > 0 ? '\n' : ''; diff --git a/src/components/Chat/ChatContent/Message/Message.tsx b/src/components/Chat/ChatContent/Message/Message.tsx index b8f6bd14..891ecb67 100644 --- a/src/components/Chat/ChatContent/Message/Message.tsx +++ b/src/components/Chat/ChatContent/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import useStore from '@store/store'; import Avatar from './Avatar'; @@ -15,17 +15,23 @@ const Message = React.memo( role, content, messageIndex, - sticky = false, + isBottomChat, + editingMessageIndex, + setEditingMessageIndex, }: { role: Role; content: string; messageIndex: number; - sticky?: boolean; + isBottomChat: boolean; + editingMessageIndex: number | null; + setEditingMessageIndex: Dispatch>; }) => { const [_content, _setContent] = useState(''); const currentChatIndex = useStore((state) => state.currentChatIndex); const setChats = useStore((state) => state.setChats); - const [isEdit, setIsEdit] = useState(sticky); + const [isEdit, setIsEdit] = useState( + isBottomChat || editingMessageIndex == messageIndex + ); useEffect(() => { if (_content === '' || useStore.getState().generating) return; @@ -38,6 +44,14 @@ const Message = React.memo( setChats(updatedChats); }, [_content]); + useEffect(() => { + if (editingMessageIndex == messageIndex) { + setIsEdit(true); + } else if (!isBottomChat) { + setIsEdit(false); + } + }, [editingMessageIndex]); + return (
- {role === 'system' && !sticky && !isEdit && ( + {role === 'system' && !isEdit && (
diff --git a/src/components/Chat/ChatContent/Message/MessageContent.tsx b/src/components/Chat/ChatContent/Message/MessageContent.tsx index af19459f..2410bffd 100644 --- a/src/components/Chat/ChatContent/Message/MessageContent.tsx +++ b/src/components/Chat/ChatContent/Message/MessageContent.tsx @@ -1,5 +1,4 @@ -import React, { useState } from 'react'; -import useStore from '@store/store'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import ContentView from './View/ContentView'; import EditView from './View/EditView'; @@ -8,17 +7,29 @@ const MessageContent = ({ role, content, messageIndex, - sticky, - isEdit, - setIsEdit, + isBottomChat, + editingMessageIndex, + setEditingMessageIndex, }: { role: string; content: string; messageIndex: number; - sticky: boolean; - isEdit: boolean; - setIsEdit: React.Dispatch>; + isBottomChat: boolean; + editingMessageIndex: number | null; + setEditingMessageIndex: Dispatch>; }) => { + const [isEdit, setIsEdit] = useState( + isBottomChat || editingMessageIndex == messageIndex + ); + + useEffect(() => { + if (editingMessageIndex == messageIndex) { + setIsEdit(true); + } else if (!isBottomChat) { + setIsEdit(false); + } + }, [editingMessageIndex]); + return (
@@ -27,14 +38,15 @@ const MessageContent = ({ content={content} setIsEdit={setIsEdit} messageIndex={messageIndex} - sticky={sticky} + sticky={isBottomChat} role={role} + setEditingMessageIndex={setEditingMessageIndex} /> ) : ( )} diff --git a/src/components/Chat/ChatContent/Message/RoleSelector.tsx b/src/components/Chat/ChatContent/Message/RoleSelector.tsx index 85c14c90..5ad13006 100644 --- a/src/components/Chat/ChatContent/Message/RoleSelector.tsx +++ b/src/components/Chat/ChatContent/Message/RoleSelector.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; @@ -11,11 +11,11 @@ const RoleSelector = React.memo( ({ role, messageIndex, - sticky, + isEdit, }: { role: Role; messageIndex: number; - sticky?: boolean; + isEdit: boolean; }) => { const { t } = useTranslation(); const setInputRole = useStore((state) => state.setInputRole); @@ -52,7 +52,7 @@ const RoleSelector = React.memo(
  • { - if (!sticky) { + if (!isEdit) { const updatedChats: ChatInterface[] = JSON.parse( JSON.stringify(useStore.getState().chats) ); diff --git a/src/components/Chat/ChatContent/Message/View/Button/EditButton.tsx b/src/components/Chat/ChatContent/Message/View/Button/EditButton.tsx index a1715e95..8526167d 100644 --- a/src/components/Chat/ChatContent/Message/View/Button/EditButton.tsx +++ b/src/components/Chat/ChatContent/Message/View/Button/EditButton.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { Dispatch, memo, SetStateAction } from 'react'; import EditIcon2 from '@icon/EditIcon2'; @@ -6,15 +6,17 @@ import BaseButton from './BaseButton'; const EditButton = memo( ({ - setIsEdit, + setEditingMessageIndex, + messageIndex, }: { - setIsEdit: React.Dispatch>; + setEditingMessageIndex: Dispatch>; + messageIndex: number; }) => { return ( } buttonProps={{ 'aria-label': 'edit message' }} - onClick={() => setIsEdit(true)} + onClick={() => setEditingMessageIndex(messageIndex)} /> ); } diff --git a/src/components/Chat/ChatContent/Message/View/Button/MarkdownModeButton.tsx b/src/components/Chat/ChatContent/Message/View/Button/MarkdownModeButton.tsx index 60d6cbbb..a49f90d6 100644 --- a/src/components/Chat/ChatContent/Message/View/Button/MarkdownModeButton.tsx +++ b/src/components/Chat/ChatContent/Message/View/Button/MarkdownModeButton.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import useStore from '@store/store'; diff --git a/src/components/Chat/ChatContent/Message/View/ContentView.tsx b/src/components/Chat/ChatContent/Message/View/ContentView.tsx index 31f4cde4..3ce50b39 100644 --- a/src/components/Chat/ChatContent/Message/View/ContentView.tsx +++ b/src/components/Chat/ChatContent/Message/View/ContentView.tsx @@ -1,7 +1,9 @@ import React, { DetailedHTMLProps, + Dispatch, HTMLAttributes, memo, + SetStateAction, useState, } from 'react'; @@ -38,12 +40,12 @@ const ContentView = memo( ({ role, content, - setIsEdit, + setEditingMessageIndex, messageIndex, }: { role: string; content: string; - setIsEdit: React.Dispatch>; + setEditingMessageIndex: Dispatch>; messageIndex: number; }) => { const { handleSubmit } = useSubmit(); @@ -153,7 +155,10 @@ const ContentView = memo( - + )} @@ -189,7 +194,7 @@ const code = memo((props: CodeProps) => { if (inline) { return {children}; } else if (lang === 'mermaid') { - return ; + return {children}; } else { return ; } diff --git a/src/components/Chat/ChatContent/Message/View/EditView.tsx b/src/components/Chat/ChatContent/Message/View/EditView.tsx index ec8c91be..136fd697 100644 --- a/src/components/Chat/ChatContent/Message/View/EditView.tsx +++ b/src/components/Chat/ChatContent/Message/View/EditView.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState } from 'react'; +import React, { memo, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import isElectron from '@utils/electron'; @@ -11,6 +11,7 @@ import TokenCount from '@components/TokenCount'; import CommandPrompt from '../CommandPrompt'; import WhisperRecord from '../WhisperRecord'; +import GlobalContext from '@hooks/GlobalContext'; const EditView = ({ content, @@ -18,12 +19,14 @@ const EditView = ({ messageIndex, sticky, role, + setEditingMessageIndex, }: { content: string; setIsEdit: React.Dispatch>; messageIndex: number; sticky?: boolean; role: string; + setEditingMessageIndex: (index: number | null) => void; }) => { const inputRole = useStore((state) => state.inputRole); const setChats = useStore((state) => state.setChats); @@ -31,10 +34,16 @@ const EditView = ({ const [cursorPosition, setCursorPosition] = useState(0); const [_content, _setContent] = useState(content); const [isModalOpen, setIsModalOpen] = useState(false); - const textareaRef = React.createRef(); - + const textareaRef = useRef(null); + const { setRef } = useContext(GlobalContext); const { t } = useTranslation(); + useEffect(() => { + if (sticky) { + setRef(textareaRef); + } + }, [textareaRef]); + const resetTextAreaHeight = () => { if (textareaRef.current) textareaRef.current.style.height = 'auto'; }; @@ -72,6 +81,7 @@ const EditView = ({ setIsEdit(false); } setChats(updatedChats); + setEditingMessageIndex(null); }; const { handleSubmit } = useSubmit(); @@ -96,6 +106,7 @@ const EditView = ({ setIsEdit(false); } setChats(updatedChats); + setEditingMessageIndex(null); handleSubmit(); }; @@ -149,6 +160,7 @@ const EditView = ({ messageIndex={messageIndex} role={role} content={content} + setEditingMessageIndex={setEditingMessageIndex} /> {isModalOpen && ( void; @@ -185,6 +198,7 @@ const EditViewButtons = memo( messageIndex: number; role: string; content: string; + setEditingMessageIndex: (index: number | null) => void; }) => { const { t } = useTranslation(); const generating = useStore.getState().generating; @@ -192,6 +206,11 @@ const EditViewButtons = memo( (state) => state.confirmEditSubmission ); + const handleCancel = () => { + setIsEdit(false); + setEditingMessageIndex(null); + }; + const handleEditGenerate = () => { if (generating) { return; @@ -256,7 +275,7 @@ const EditViewButtons = memo( className={`btn relative ${ messageIndex % 2 ? 'btn-neutral' : 'btn-neutral-dark' }`} - onClick={() => setIsEdit(false)} + onClick={() => handleCancel()} aria-label={t('cancel') as string} >
    diff --git a/src/components/Chat/ChatContent/Message/WhisperRecord.tsx b/src/components/Chat/ChatContent/Message/WhisperRecord.tsx index 62c21529..e60a79fa 100644 --- a/src/components/Chat/ChatContent/Message/WhisperRecord.tsx +++ b/src/components/Chat/ChatContent/Message/WhisperRecord.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useWhisper } from '@chengsokdara/use-whisper'; import useStore from '@store/store'; import StopIcon from '@icon/StopIcon'; @@ -14,16 +14,17 @@ const WhisperRecord = ({ messageIndex: number; }) => { let apiKey = useStore((state) => state.apiKey); - + const setGenerating = useStore((state) => state.setGenerating); apiKey = apiKey || '0'; const { transcript, startRecording, stopRecording } = useWhisper({ apiKey }); useEffect(() => { - if (transcript.text) { + if (transcript.text != null) { _setContent((prev) => { return prev.replace('◯', transcript.text || ''); }); + setGenerating(false); } }, [transcript.text]); @@ -37,16 +38,16 @@ const WhisperRecord = ({ stopRecording(); } else { _setContent((prev) => { - let startContent = prev.slice(0, cursorPosition); - let endContent = prev.slice(cursorPosition); + const startContent = prev.slice(0, cursorPosition); + const endContent = prev.slice(cursorPosition); - let paddedStart = + const paddedStart = !startContent.endsWith(' ') && !startContent.endsWith('\n') && startContent.length > 0 ? ' ' : ''; - let paddedEnd = + const paddedEnd = !endContent.startsWith(' ') && !endContent.startsWith('\n') ? ' ' : ''; @@ -54,6 +55,7 @@ const WhisperRecord = ({ return startContent + paddedStart + '◉' + paddedEnd + endContent; }); startRecording(); + setGenerating(true); } setIsRecording(!isRecording); }; diff --git a/src/components/Chat/ChatContent/ModelConfigBar.tsx b/src/components/Chat/ChatContent/ModelConfigBar.tsx index 3de69c2c..ea1747c1 100644 --- a/src/components/Chat/ChatContent/ModelConfigBar.tsx +++ b/src/components/Chat/ChatContent/ModelConfigBar.tsx @@ -23,10 +23,6 @@ const ModelConfigBar = React.memo(() => { const setChats = useStore((state) => state.setChats); const currentChatIndex = useStore((state) => state.currentChatIndex); const [isModalOpen, setIsModalOpen] = useState(false); - const [_model, _setModel] = useState( - config?.model || 'gpt-3.5-turbo' - ); - const setConfig = (config: ConfigInterface) => { const updatedChats: ChatInterface[] = JSON.parse( JSON.stringify(useStore.getState().chats) diff --git a/src/components/Chat/ChatContent/ScrollToBottomButton.tsx b/src/components/Chat/ChatContent/ScrollToBottomButton.tsx deleted file mode 100644 index e82694aa..00000000 --- a/src/components/Chat/ChatContent/ScrollToBottomButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { useAtBottom, useScrollToBottom } from 'react-scroll-to-bottom'; - -import DownArrow from '@icon/DownArrow'; - -const ScrollToBottomButton = React.memo(() => { - const scrollToBottom = useScrollToBottom(); - const [atBottom] = useAtBottom(); - - return ( - - ); -}); - -export default ScrollToBottomButton; diff --git a/src/components/ChatConfigMenu/ChatConfigMenu.tsx b/src/components/ChatConfigMenu/ChatConfigMenu.tsx index e6638f60..ceab6547 100644 --- a/src/components/ChatConfigMenu/ChatConfigMenu.tsx +++ b/src/components/ChatConfigMenu/ChatConfigMenu.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import useStore from '@store/store'; import { useTranslation } from 'react-i18next'; -import ChatIcon from '@icon/ChatIcon'; import PopupModal from '@components/PopupModal'; import { @@ -51,7 +50,9 @@ const ChatConfigPopup = ({ ); const [_model, _setModel] = useState(config.model); const [_maxToken, _setMaxToken] = useState(config.max_tokens); - const [_maxContext, _setMaxContext] = useState(config.max_context); + const [_maxContext, _setMaxContext] = useState( + config.max_context ?? 0 + ); const [_temperature, _setTemperature] = useState(config.temperature); const [_topP, _setTopP] = useState(config.top_p); const [_presencePenalty, _setPresencePenalty] = useState( @@ -80,7 +81,7 @@ const ChatConfigPopup = ({ const handleReset = () => { _setModel(_defaultChatConfig.model); _setMaxToken(_defaultChatConfig.max_tokens); - _setMaxContext(_defaultChatConfig.max_context); + _setMaxContext(_defaultChatConfig.max_context ?? 0); _setTemperature(_defaultChatConfig.temperature); _setTopP(_defaultChatConfig.top_p); _setPresencePenalty(_defaultChatConfig.presence_penalty); diff --git a/src/components/ConfigMenu/ConfigMenu.tsx b/src/components/ConfigMenu/ConfigMenu.tsx index 2dbb27cb..f71fe6fe 100644 --- a/src/components/ConfigMenu/ConfigMenu.tsx +++ b/src/components/ConfigMenu/ConfigMenu.tsx @@ -1,9 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; -import useStore from '@store/store'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import PopupModal from '@components/PopupModal'; import { ConfigInterface, ModelChoice } from '@type/chat'; -import { modelMaxToken } from '@constants/chat'; import { ModelSelect } from './ModelSelect'; import { FrequencyPenaltySlider, @@ -24,7 +22,9 @@ const ConfigMenu = ({ setConfig: (config: ConfigInterface) => void; }) => { const [_maxToken, _setMaxToken] = useState(config.max_tokens); - const [_maxContext, _setMaxContext] = useState(config.max_context); + const [_maxContext, _setMaxContext] = useState( + config.max_context ?? 0 + ); const [_model, _setModel] = useState(config.model); const [_temperature, _setTemperature] = useState(config.temperature); const [_presencePenalty, _setPresencePenalty] = useState( diff --git a/src/components/ConfigMenu/SettingsSliders.tsx b/src/components/ConfigMenu/SettingsSliders.tsx index 29a8e4be..723ff7b3 100644 --- a/src/components/ConfigMenu/SettingsSliders.tsx +++ b/src/components/ConfigMenu/SettingsSliders.tsx @@ -1,10 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; -import useStore from '@store/store'; +import React, { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import PopupModal from '@components/PopupModal'; -import { ConfigInterface, ModelChoice } from '@type/chat'; +import { ModelChoice } from '@type/chat'; import { modelMaxToken } from '@constants/chat'; -import { ModelSelect } from './ModelSelect'; export const MaxTokenSlider = ({ _maxToken, diff --git a/src/components/GoogleSync/GoogleSyncButton.tsx b/src/components/GoogleSync/GoogleSyncButton.tsx index 3a1c43fe..4450fe71 100644 --- a/src/components/GoogleSync/GoogleSyncButton.tsx +++ b/src/components/GoogleSync/GoogleSyncButton.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { useGoogleLogin, googleLogout } from '@react-oauth/google'; diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx index 90169776..6a55b4bf 100644 --- a/src/components/LanguageSelector/LanguageSelector.tsx +++ b/src/components/LanguageSelector/LanguageSelector.tsx @@ -1,9 +1,8 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import DownChevronArrow from '@icon/DownChevronArrow'; import { languageCodeToName, selectableLanguages } from '@constants/language'; -import FileTextIcon from '@icon/FileTextIcon'; import LanguageIcon from '@icon/LanguageIcon'; import useHideOnOutsideClick from '@hooks/useHideOnOutsideClick'; diff --git a/src/components/Menu/ChatHistory.tsx b/src/components/Menu/ChatHistory.tsx index e92a3688..90971a91 100644 --- a/src/components/Menu/ChatHistory.tsx +++ b/src/components/Menu/ChatHistory.tsx @@ -108,7 +108,7 @@ const ChatHistory = React.memo( onChange={(e) => { _setTitle(e.target.value); }} - onBlur={(e) => { + onBlur={() => { setIsEdit(false); }} onKeyDown={handleKeyDown} diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index f945e1e9..bf162c76 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -8,8 +8,6 @@ import ChatHistoryList from './ChatHistoryList'; import MenuOptions from './MenuOptions'; import CrossIcon2 from '@icon/CrossIcon2'; -import DownArrow from '@icon/DownArrow'; -import MenuIcon from '@icon/MenuIcon'; const Menu = () => { const hideSideMenu = useStore((state) => state.hideSideMenu); diff --git a/src/components/Menu/MenuOptions/CollapseOptions.tsx b/src/components/Menu/MenuOptions/CollapseOptions.tsx index bdc63148..903bf136 100644 --- a/src/components/Menu/MenuOptions/CollapseOptions.tsx +++ b/src/components/Menu/MenuOptions/CollapseOptions.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import ArrowBottom from '@icon/ArrowBottom'; import useStore from '@store/store'; diff --git a/src/components/Menu/MenuOptions/DesktopLink.tsx b/src/components/Menu/MenuOptions/DesktopLink.tsx index 6d2577b2..5aeb43fa 100644 --- a/src/components/Menu/MenuOptions/DesktopLink.tsx +++ b/src/components/Menu/MenuOptions/DesktopLink.tsx @@ -9,6 +9,7 @@ const DesktopLink = () => { className='flex py-2 px-1.5 items-center gap-2.5 rounded-md hover:bg-custom-white/20 transition-colors duration-200 text-custom-white cursor-pointer text-sm' href='https://github.com/jackschedel/KoalaClient/releases' target='_blank' + rel='noreferrer' > {t('desktop')} diff --git a/src/components/Menu/MenuOptions/GithubLink.tsx b/src/components/Menu/MenuOptions/GithubLink.tsx index ba3d576b..823e34ee 100644 --- a/src/components/Menu/MenuOptions/GithubLink.tsx +++ b/src/components/Menu/MenuOptions/GithubLink.tsx @@ -9,6 +9,7 @@ const GithubLink = () => { className='flex py-2 px-2 items-center gap-3 rounded-md hover:bg-custom-white/20 transition-colors duration-200 text-custom-white cursor-pointer text-sm' href='https://github.com/jackschedel/KoalaClient' target='_blank' + rel='noreferrer' > {t('host')} diff --git a/src/components/Menu/MenuOptions/MenuOptions.tsx b/src/components/Menu/MenuOptions/MenuOptions.tsx index 6150c625..78d8b657 100644 --- a/src/components/Menu/MenuOptions/MenuOptions.tsx +++ b/src/components/Menu/MenuOptions/MenuOptions.tsx @@ -1,19 +1,12 @@ import React from 'react'; -import useStore from '@store/store'; - import SettingsMenu from '@components/SettingsMenu'; -import CollapseOptions from './CollapseOptions'; import { GoogleSync } from '@components/GoogleSync/GoogleSync'; -import { TotalTokenCostDisplay } from '@components/SettingsMenu/TotalTokenCost'; import isElectron from '@utils/electron'; -import GithubLink from './GithubLink'; import DesktopLink from './DesktopLink'; const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || undefined; const MenuOptions = () => { - const hideMenuOptions = useStore((state) => state.hideMenuOptions); - const countTotalTokens = useStore((state) => state.countTotalTokens); return ( <>
    diff --git a/src/components/Menu/NewChat.tsx b/src/components/Menu/NewChat.tsx index d598ea9a..a4e5db85 100644 --- a/src/components/Menu/NewChat.tsx +++ b/src/components/Menu/NewChat.tsx @@ -1,14 +1,16 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import useStore from '@store/store'; import PlusIcon from '@icon/PlusIcon'; import useAddChat from '@hooks/useAddChat'; +import GlobalContext from '@hooks/GlobalContext'; const NewChat = ({ folder }: { folder?: string }) => { const { t } = useTranslation(); const addChat = useAddChat(); + const { ref } = useContext(GlobalContext); const generating = useStore((state) => state.generating); return ( @@ -23,7 +25,10 @@ const NewChat = ({ folder }: { folder?: string }) => { : 'text-custom-white gap-2 mb-2 border border-custom-white/20' }`} onClick={() => { - if (!generating) addChat(folder); + if (!generating) { + addChat(folder); + ref?.current?.focus(); + } }} title={String(t('newChat'))} > diff --git a/src/components/Menu/NewFolder.tsx b/src/components/Menu/NewFolder.tsx index 94448a64..cd8bdc00 100644 --- a/src/components/Menu/NewFolder.tsx +++ b/src/components/Menu/NewFolder.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; import { v4 as uuidv4 } from 'uuid'; import useStore from '@store/store'; @@ -7,7 +6,6 @@ import NewFolderIcon from '@icon/NewFolderIcon'; import { Folder, FolderCollection } from '@type/chat'; const NewFolder = () => { - const { t } = useTranslation(); const generating = useStore((state) => state.generating); const setFolders = useStore((state) => state.setFolders); @@ -43,7 +41,7 @@ const NewFolder = () => { return ( { @@ -17,9 +21,8 @@ const MobileBar = () => { const setHideSideMenu = useStore((state) => state.setHideSideMenu); const hideSideMenu = useStore((state) => state.hideSideMenu); const currentChatIndex = useStore((state) => state.currentChatIndex); - const setCurrentChatIndex = useStore((state) => state.setCurrentChatIndex); const chats = useStore((state) => state.chats); - + const { ref } = useContext(GlobalContext); const cloudSync = useGStore((state) => state.cloudSync); const syncStatus = useGStore((state) => state.syncStatus); @@ -33,6 +36,8 @@ const MobileBar = () => { ); const addChat = useAddChat(); + const goBack = useGoBack(); + const goForward = useGoForward(); return (
    @@ -55,10 +60,7 @@ const MobileBar = () => {