From 89a763dff7211da45adb5b172e852d246cc8828b Mon Sep 17 00:00:00 2001 From: ayangweb <75017711+ayangweb@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:07:34 +0800 Subject: [PATCH] feat: supports keyboard shortcuts with immediate effect (#316) * feat: supports keyboard shortcuts with immediate effect * feat: customize mode switching shortcuts * refactor: remove the shift * fix: voice input audio input device number anomaly issue * feat: support for changing the focus state of the input box * refactor: shortcuts for handling input box focus separately * feat: upload file support shortcuts * refactor: the connection timeout is specified with the variable * refactor: shortcut keys to modify the input box before displaying modifier keys * docs: update changelog * style: remove useless import * refactor: window focus changes modifier key press status to false * refactor: correcting errors of judgment * docs: update changelog --- docs/content.en/docs/release-notes/_index.md | 2 +- src/components/AudioRecording/index.tsx | 40 +++++- src/components/Common/ChatSwitch.tsx | 13 +- src/components/Search/InputBox.tsx | 48 +++++-- src/components/Search/InputExtra.tsx | 60 ++++++--- src/components/Search/NoResults.tsx | 2 +- .../Advanced/components/Shortcuts/index.tsx | 24 ++-- src/hooks/useMessageHandler.ts | 24 +++- src/pages/main/index.tsx | 119 +++++++++++++++--- src/pages/web/SearchChat.tsx | 40 ------ src/stores/shortcutsStore.ts | 67 +++++----- src/utils/keyboardUtils.ts | 2 + src/utils/platformAdapter.ts | 7 +- 13 files changed, 309 insertions(+), 139 deletions(-) diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index beb59e6a..307cddce 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -12,6 +12,7 @@ Information about release notes of Coco Server is provided here. ### Breaking changes - feat: add web pages components #277 +- feat: support for customizing some of the preset shortcuts #316 ### Features @@ -19,7 +20,6 @@ Information about release notes of Coco Server is provided here. - feat: support multi websocket connections #314 - feat: add support for embeddable web widget #277 - ### Bug fix ### Improvements diff --git a/src/components/AudioRecording/index.tsx b/src/components/AudioRecording/index.tsx index 36fd8d39..b17d60bf 100644 --- a/src/components/AudioRecording/index.tsx +++ b/src/components/AudioRecording/index.tsx @@ -1,5 +1,5 @@ import { useAppStore } from "@/stores/appStore"; -import { useReactive } from "ahooks"; +import { useKeyPress, useReactive } from "ahooks"; import clsx from "clsx"; import { Check, Loader, Mic, X } from "lucide-react"; import { FC, useEffect, useRef } from "react"; @@ -11,6 +11,7 @@ import { useWavesurfer } from "@wavesurfer/react"; import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js"; import { transcription } from "@/api/transcription"; import { useConnectStore } from "@/stores/connectStore"; +import { useShortcutsStore } from "@/stores/shortcutsStore"; interface AudioRecordingProps { onChange?: (text: string) => void; @@ -39,6 +40,13 @@ const AudioRecording: FC = (props) => { const recordRef = useRef(); const withVisibility = useAppStore((state) => state.withVisibility); const currentService = useConnectStore((state) => state.currentService); + const modifierKeyPressed = useShortcutsStore((state) => { + return state.modifierKeyPressed; + }); + const modifierKey = useShortcutsStore((state) => { + return state.modifierKey; + }); + const voiceInput = useShortcutsStore((state) => state.voiceInput); const { wavesurfer } = useWavesurfer({ container: containerRef, @@ -67,6 +75,8 @@ const AudioRecording: FC = (props) => { ); record.on("record-end", (blob) => { + if (!state.converting) return; + const reader = new FileReader(); reader.onloadend = async () => { @@ -103,6 +113,10 @@ const AudioRecording: FC = (props) => { }, 1000); }, [state.isRecording]); + useKeyPress(`${modifierKey}.${voiceInput}`, () => { + startRecording(); + }); + const getAvailableAudioDevices = async () => { state.audioDevices = await RecordPlugin.getAvailableAudioDevices(); }; @@ -110,7 +124,11 @@ const AudioRecording: FC = (props) => { const resetState = (otherState: Partial = {}) => { clearInterval(interval); recordRef.current?.stopRecording(); - Object.assign(state, { ...INITIAL_STATE, ...otherState }); + Object.assign(state, { + ...INITIAL_STATE, + ...otherState, + audioDevices: state.audioDevices, + }); }; const checkPermission = async () => { @@ -153,7 +171,23 @@ const AudioRecording: FC = (props) => { } )} > - + + +
+ {voiceInput} +
= ({ isChatMode, onChange }) => { + const modifierKeyPressed = useShortcutsStore((state) => { + return state.modifierKeyPressed; + }); + const modeSwitch = useShortcutsStore((state) => { + return state.modeSwitch; + }); + const handleToggle = useCallback(() => { onChange?.(!isChatMode); }, [onChange, isChatMode]); const handleKeydown = useCallback( (event: KeyboardEvent) => { - if (isMetaOrCtrlKey(event) && event.key === "t") { + if (modifierKeyPressed && event.key === modeSwitch.toLowerCase()) { event.preventDefault(); // console.log("Switch mode triggered"); handleToggle(); } }, - [handleToggle] + [handleToggle, modifierKeyPressed, modeSwitch] ); useEffect(() => { diff --git a/src/components/Search/InputBox.tsx b/src/components/Search/InputBox.tsx index f12e9dee..367735ed 100644 --- a/src/components/Search/InputBox.tsx +++ b/src/components/Search/InputBox.tsx @@ -16,6 +16,8 @@ import { hide_coco } from "@/commands"; import { DataSource } from "@/types/commands"; import InputExtra from "./InputExtra"; import { useConnectStore } from "@/stores/connectStore"; +import { useShortcutsStore } from "@/stores/shortcutsStore"; +import { useKeyPress } from "ahooks"; interface ChatInputProps { onSend: (message: string) => void; @@ -73,7 +75,7 @@ export default function ChatInput({ getFileIcon, }: ChatInputProps) { const { t } = useTranslation(); - + const showTooltip = useAppStore( (state: { showTooltip: boolean }) => state.showTooltip ); @@ -88,6 +90,14 @@ export default function ChatInput({ ); const sessionId = useConnectStore((state) => state.currentSessionId); + const modifierKey = useShortcutsStore((state) => { + return state.modifierKey; + }); + const modifierKeyPressed = useShortcutsStore((state) => { + return state.modifierKeyPressed; + }); + const modeSwitch = useShortcutsStore((state) => state.modeSwitch); + const returnToInput = useShortcutsStore((state) => state.returnToInput); useEffect(() => { return () => { @@ -109,7 +119,7 @@ export default function ChatInput({ setReconnectCountdown(0); return; } - + if (reconnectCountdown > 0) { const timer = setTimeout(() => { setReconnectCountdown(reconnectCountdown - 1); @@ -119,6 +129,22 @@ export default function ChatInput({ }, [reconnectCountdown, connected]); const [isCommandPressed, setIsCommandPressed] = useState(false); + const setModifierKeyPressed = useShortcutsStore((state) => { + return state.setModifierKeyPressed; + }); + + useEffect(() => { + const handleFocus = () => { + setIsCommandPressed(false); + setModifierKeyPressed(false); + }; + + window.addEventListener("focus", handleFocus); + + return () => { + window.removeEventListener("focus", handleFocus); + }; + }, []); const handleToggleFocus = useCallback(() => { if (isChatMode) { @@ -146,6 +172,8 @@ export default function ChatInput({ } }, [inputValue, isPinned]); + useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // console.log("handleKeyDown", e.code, e.key); @@ -167,8 +195,6 @@ export default function ChatInput({ case "Comma": setIsCommandPressed(false); break; - case "KeyI": - handleToggleFocus(); break; case "ArrowLeft": setSourceData(undefined); @@ -299,13 +325,13 @@ export default function ChatInput({ ←
) : null} - {showTooltip && isCommandPressed ? ( + {showTooltip && modifierKeyPressed ? (
- I + {returnToInput}
) : null} @@ -344,13 +370,13 @@ export default function ChatInput({ ) : null} - {showTooltip && isChatMode && isCommandPressed ? ( + {/* {showTooltip && isChatMode && isCommandPressed ? (
M
- ) : null} + ) : null} */} {showTooltip && isChatMode && isCommandPressed ? (
{reconnectCountdown > 0 - ? `${t("search.input.connecting")}(${reconnectCountdown}s)` + ? `${t("search.input.connecting")}(${reconnectCountdown}s)` : t("search.input.reconnect")}
@@ -440,11 +466,11 @@ export default function ChatInput({ {isChatPage ? null : (
- {showTooltip && isCommandPressed ? ( + {showTooltip && modifierKeyPressed ? (
- T + {modeSwitch}
) : null} state.uploadFiles); const setUploadFiles = useChatStore((state) => state.setUploadFiles); const withVisibility = useAppStore((state) => state.withVisibility); + const modifierKey = useShortcutsStore((state) => { + return state.modifierKey; + }); + const addFile = useShortcutsStore((state) => { + return state.addFile; + }); + const modifierKeyPressed = useShortcutsStore((state) => { + return state.modifierKeyPressed; + }); const state = useReactive({ screenshotableMonitors: [], @@ -72,6 +83,18 @@ const InputExtra = ({ state.screenRecordingPermission = await checkScreenPermission(); }); + const handleSelectFile = async () => { + const selectedFiles = await withVisibility(() => { + return openFileDialog({ + multiple: true, + }); + }); + + if (isNil(selectedFiles)) return; + + handleUploadFiles(selectedFiles); + }; + const handleUploadFiles = async (paths: string | string[]) => { const files: typeof uploadFiles = []; @@ -99,17 +122,7 @@ const InputExtra = ({ const menuItems: MenuItem[] = [ { label: t("search.input.uploadFile"), - clickEvent: async () => { - const selectedFiles = await withVisibility(() => { - return openFileDialog({ - multiple: true, - }); - }); - - if (isNil(selectedFiles)) return; - - handleUploadFiles(selectedFiles); - }, + clickEvent: handleSelectFile, }, { label: t("search.input.screenshot"), @@ -167,12 +180,29 @@ const InputExtra = ({ i18n.language, ]); + useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile); + return ( - + -
- +
+ + +
+ {addFile} +
diff --git a/src/components/Search/NoResults.tsx b/src/components/Search/NoResults.tsx index 820fcb7b..dbc1f431 100644 --- a/src/components/Search/NoResults.tsx +++ b/src/components/Search/NoResults.tsx @@ -35,4 +35,4 @@ export const NoResults = () => {
); -}; \ No newline at end of file +}; diff --git a/src/components/Settings/Advanced/components/Shortcuts/index.tsx b/src/components/Settings/Advanced/components/Shortcuts/index.tsx index 2f3ca8ce..7cad2592 100644 --- a/src/components/Settings/Advanced/components/Shortcuts/index.tsx +++ b/src/components/Settings/Advanced/components/Shortcuts/index.tsx @@ -6,6 +6,8 @@ import { Command } from "lucide-react"; import { ChangeEvent, useEffect } from "react"; import { emit } from "@tauri-apps/api/event"; +export const modifierKeys: ModifierKey[] = ["meta", "ctrl"]; + const Shortcuts = () => { const { t } = useTranslation(); const modifierKey = useShortcutsStore((state) => state.modifierKey); @@ -106,7 +108,7 @@ const Shortcuts = () => { setModifierKey(event.target.value as ModifierKey); }} > - {["Command", "Control", "Option"].map((item) => { + {modifierKeys.map((item) => { return ; })} @@ -122,14 +124,18 @@ const Shortcuts = () => { title={t(title)} description={t(description)} > - { - handleChange(event, setValue); - }} - /> +
+ {formatKey(modifierKey)} + + + { + handleChange(event, setValue); + }} + /> +
); })} diff --git a/src/hooks/useMessageHandler.ts b/src/hooks/useMessageHandler.ts index bc99c303..5ebdd7b7 100644 --- a/src/hooks/useMessageHandler.ts +++ b/src/hooks/useMessageHandler.ts @@ -1,13 +1,18 @@ import { useCallback, useRef } from "react"; import type { IChunkData, Chat } from "@/components/Assistant/types"; +import { useConnectStore } from "@/stores/connectStore"; export function useMessageHandler( curIdRef: React.MutableRefObject, setCurChatEnd: (value: boolean) => void, setTimedoutShow: (value: boolean) => void, onCancel: (chat?: Chat) => void, - setLoadingStep: (value: Record | ((prev: Record) => Record)) => void, + setLoadingStep: ( + value: + | Record + | ((prev: Record) => Record) + ) => void, handlers: { deal_query_intent: (data: IChunkData) => void; deal_fetch_source: (data: IChunkData) => void; @@ -15,9 +20,10 @@ export function useMessageHandler( deal_deep_read: (data: IChunkData) => void; deal_think: (data: IChunkData) => void; deal_response: (data: IChunkData) => void; - }, + } ) { const messageTimeoutRef = useRef(); + const connectionTimeout = useConnectStore((state) => state.connectionTimeout); const dealMsg = useCallback( (msg: string) => { @@ -31,7 +37,7 @@ export function useMessageHandler( console.log("AI response timeout"); setTimedoutShow(true); onCancel(); - }, 120000); + }, (connectionTimeout ?? 120) * 1000); const cleanedData = msg.replace(/^PRIVATE /, ""); try { @@ -73,11 +79,17 @@ export function useMessageHandler( console.error("parse error:", error); } }, - [onCancel, setCurChatEnd, setTimedoutShow, curIdRef.current] + [ + onCancel, + setCurChatEnd, + setTimedoutShow, + curIdRef.current, + connectionTimeout, + ] ); return { dealMsg, - messageTimeoutRef + messageTimeoutRef, }; -} \ No newline at end of file +} diff --git a/src/pages/main/index.tsx b/src/pages/main/index.tsx index 33d399df..83402a68 100644 --- a/src/pages/main/index.tsx +++ b/src/pages/main/index.tsx @@ -1,16 +1,23 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import SearchChat from "@/pages/web/SearchChat"; import platformAdapter from "@/utils/platformAdapter"; +import { useShortcutsStore } from "@/stores/shortcutsStore"; +import { useStartupStore } from "@/stores/startupStore"; +import { useKeyPress } from "ahooks"; +import { modifierKeys } from "@/components/Settings/Advanced/components/Shortcuts"; function MainApp() { const querySearch = useCallback(async (input: string) => { try { - const response: any = await platformAdapter.invokeBackend("query_coco_fusion", { - from: 0, - size: 10, - queryStrings: { query: input }, - }); + const response: any = await platformAdapter.invokeBackend( + "query_coco_fusion", + { + from: 0, + size: 10, + queryStrings: { query: input }, + } + ); return response; } catch (error) { console.error("query_coco_fusion error:", error); @@ -21,11 +28,14 @@ function MainApp() { const queryDocuments = useCallback( async (from: number, size: number, queryStrings: any) => { try { - const response: any = await platformAdapter.invokeBackend("query_coco_fusion", { - from, - size, - queryStrings, - }); + const response: any = await platformAdapter.invokeBackend( + "query_coco_fusion", + { + from, + size, + queryStrings, + } + ); return response; } catch (error) { console.error("query_coco_fusion error:", error); @@ -34,13 +44,92 @@ function MainApp() { }, [] ); + const modifierKey = useShortcutsStore((state) => { + return state.modifierKey; + }); + const setModifierKey = useShortcutsStore((state) => { + return state.setModifierKey; + }); + const setModifierKeyPressed = useShortcutsStore((state) => { + return state.setModifierKeyPressed; + }); + const setModeSwitch = useShortcutsStore((state) => { + return state.setModeSwitch; + }); + const setReturnToInput = useShortcutsStore((state) => { + return state.setReturnToInput; + }); + const setVoiceInput = useShortcutsStore((state) => { + return state.setVoiceInput; + }); + const setAddImage = useShortcutsStore((state) => { + return state.setAddImage; + }); + const setAddFile = useShortcutsStore((state) => { + return state.setAddFile; + }); + const setDefaultStartupWindow = useStartupStore((state) => { + return state.setDefaultStartupWindow; + }); + const setDefaultContentForSearchWindow = useStartupStore((state) => { + return state.setDefaultContentForSearchWindow; + }); + const setDefaultContentForChatWindow = useStartupStore((state) => { + return state.setDefaultContentForChatWindow; + }); + useEffect(() => { + const unListeners = Promise.all([ + platformAdapter.listenEvent("change-shortcuts-store", ({ payload }) => { + const { + modifierKey, + modeSwitch, + returnToInput, + voiceInput, + addImage, + addFile, + } = payload; + setModifierKey(modifierKey); + setModeSwitch(modeSwitch); + setReturnToInput(returnToInput); + setVoiceInput(voiceInput); + setAddImage(addImage); + setAddFile(addFile); + }), + + platformAdapter.listenEvent("change-startup-store", ({ payload }) => { + const { + defaultStartupWindow, + defaultContentForSearchWindow, + defaultContentForChatWindow, + } = payload; + setDefaultStartupWindow(defaultStartupWindow); + setDefaultContentForSearchWindow(defaultContentForSearchWindow); + setDefaultContentForChatWindow(defaultContentForChatWindow); + }), + ]); + + return () => { + unListeners.then((fns) => { + fns.forEach((fn) => fn()); + }); + }; + }, []); + + useKeyPress( + modifierKeys, + (event, key) => { + if (key === modifierKey) { + setModifierKeyPressed(event.type === "keydown"); + } + }, + { + events: ["keydown", "keyup"], + } + ); return ( - + ); } diff --git a/src/pages/web/SearchChat.tsx b/src/pages/web/SearchChat.tsx index 5d7924a7..4e54bd39 100644 --- a/src/pages/web/SearchChat.tsx +++ b/src/pages/web/SearchChat.tsx @@ -172,49 +172,9 @@ function SearchChat({ querySearch, queryDocuments }: SearchChatProps) { const defaultStartupWindow = useStartupStore((state) => { return state.defaultStartupWindow; }); - const setDefaultStartupWindow = useStartupStore((state) => { - return state.setDefaultStartupWindow; - }); const showCocoListenRef = useRef<(() => void) | undefined>(); - useEffect(() => { - let unlistenChangeStartupStore: (() => void) | undefined; - - const setupListener = async () => { - try { - unlistenChangeStartupStore = await platformAdapter.listenEvent( - "change-startup-store", - ({ payload }) => { - if ( - payload && - typeof payload === "object" && - "defaultStartupWindow" in payload - ) { - const startupWindow = payload.defaultStartupWindow; - if ( - startupWindow === "searchMode" || - startupWindow === "chatMode" - ) { - setDefaultStartupWindow(startupWindow); - } - } - } - ); - } catch (error) { - console.error("Error setting up change-startup-store listener:", error); - } - }; - - setupListener(); - - return () => { - if (unlistenChangeStartupStore) { - unlistenChangeStartupStore(); - } - }; - }, []); - useEffect(() => { const setupShowCocoListener = async () => { if (showCocoListenRef.current) { diff --git a/src/stores/shortcutsStore.ts b/src/stores/shortcutsStore.ts index bf2e0269..dd554b7d 100644 --- a/src/stores/shortcutsStore.ts +++ b/src/stores/shortcutsStore.ts @@ -1,12 +1,14 @@ import { isMac } from "@/utils/platform"; import { create } from "zustand"; -import { persist, subscribeWithSelector } from "zustand/middleware"; +import { persist } from "zustand/middleware"; -export type ModifierKey = "Command" | "Control" | "Option"; +export type ModifierKey = "meta" | "ctrl" | "alt"; export type IShortcutsStore = { modifierKey: ModifierKey; setModifierKey: (modifierKey: ModifierKey) => void; + modifierKeyPressed: boolean; + setModifierKeyPressed: (modifierKeyPressed: boolean) => void; modeSwitch: string; setModeSwitch: (modeSwitch: string) => void; returnToInput: string; @@ -22,36 +24,37 @@ export type IShortcutsStore = { }; export const useShortcutsStore = create()( - subscribeWithSelector( - persist( - (set) => ({ - modifierKey: isMac ? "Command" : "Control", - setModifierKey: (modifierKey: ModifierKey) => set({ modifierKey }), - modeSwitch: "T", - setModeSwitch: (modeSwitch: string) => set({ modeSwitch }), - returnToInput: "I", - setReturnToInput: (returnToInput: string) => set({ returnToInput }), - voiceInput: "N", - setVoiceInput: (voiceInput: string) => set({ voiceInput }), - addImage: "G", - setAddImage: (addImage: string) => set({ addImage }), - selectLlmModel: "O", - setSelectLlmModel: (selectLlmModel: string) => set({ selectLlmModel }), - addFile: "U", - setAddFile: (addFile: string) => set({ addFile }), + persist( + (set) => ({ + modifierKey: isMac ? "meta" : "ctrl", + setModifierKey: (modifierKey: ModifierKey) => set({ modifierKey }), + modifierKeyPressed: false, + setModifierKeyPressed: (modifierKeyPressed: boolean) => + set({ modifierKeyPressed }), + modeSwitch: "T", + setModeSwitch: (modeSwitch: string) => set({ modeSwitch }), + returnToInput: "I", + setReturnToInput: (returnToInput: string) => set({ returnToInput }), + voiceInput: "N", + setVoiceInput: (voiceInput: string) => set({ voiceInput }), + addImage: "G", + setAddImage: (addImage: string) => set({ addImage }), + selectLlmModel: "O", + setSelectLlmModel: (selectLlmModel: string) => set({ selectLlmModel }), + addFile: "U", + setAddFile: (addFile: string) => set({ addFile }), + }), + { + name: "shortcuts-store", + partialize: (state) => ({ + modifierKey: state.modifierKey, + modeSwitch: state.modeSwitch, + returnToInput: state.returnToInput, + voiceInput: state.voiceInput, + addImage: state.addImage, + selectLlmModel: state.selectLlmModel, + addFile: state.addFile, }), - { - name: "shortcuts-store", - partialize: (state) => ({ - modifierKey: state.modifierKey, - modeSwitch: state.modeSwitch, - returnToInput: state.returnToInput, - voiceInput: state.voiceInput, - addImage: state.addImage, - selectLlmModel: state.selectLlmModel, - addFile: state.addFile, - }), - } - ) + } ) ); diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 38ce3f33..4ebdf4cd 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -21,6 +21,8 @@ export const KEY_SYMBOLS: Record = { // Modifier keys Control: isMac ? "⌃" : "Ctrl", control: isMac ? "⌃" : "Ctrl", + Ctrl: isMac ? "⌃" : "Ctrl", + ctrl: isMac ? "⌃" : "Ctrl", Shift: isMac ? "⇧" : "Shift", shift: isMac ? "⇧" : "Shift", Alt: isMac ? "⌥" : "Alt", diff --git a/src/utils/platformAdapter.ts b/src/utils/platformAdapter.ts index c119e9b3..ce4b8217 100644 --- a/src/utils/platformAdapter.ts +++ b/src/utils/platformAdapter.ts @@ -2,6 +2,8 @@ import { useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { convertFileSrc as tauriConvertFileSrc } from "@tauri-apps/api/core"; import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog"; +import { IShortcutsStore } from "@/stores/shortcutsStore"; +import { IStartupStore } from "@/stores/startupStore"; export interface EventPayloads { "language-changed": { @@ -27,10 +29,9 @@ export interface EventPayloads { open_settings: string | ""; tab_index: string | ""; login_or_logout: any; - "change-startup-store": { - defaultStartupWindow: string; - }; + "change-startup-store": IStartupStore "show-coco": void; + "change-shortcuts-store": IShortcutsStore; } // Platform adapter interface