mirror of
				https://github.com/ggml-org/llama.cpp.git
				synced 2025-10-31 08:51:55 +00:00 
			
		
		
		
	webui : Replace alert and confirm with custom modals. (#13711)
* Replace alert and confirm with custom modals. This is needed as Webview in VS Code doesn't permit alert and confirm for security reasons. * use Modal Provider to simplify the use of confirm and alert modals. * Increase the z index of the modal dialogs. * Update index.html.gz * also add showPrompt * rebuild --------- Co-authored-by: igardev <ivailo.gardev@akros.ch> Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							| @@ -5,9 +5,11 @@ import { AppContextProvider, useAppContext } from './utils/app.context'; | |||||||
| import ChatScreen from './components/ChatScreen'; | import ChatScreen from './components/ChatScreen'; | ||||||
| import SettingDialog from './components/SettingDialog'; | import SettingDialog from './components/SettingDialog'; | ||||||
| import { Toaster } from 'react-hot-toast'; | import { Toaster } from 'react-hot-toast'; | ||||||
|  | import { ModalProvider } from './components/ModalProvider'; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|   return ( |   return ( | ||||||
|  |     <ModalProvider> | ||||||
|       <HashRouter> |       <HashRouter> | ||||||
|         <div className="flex flex-row drawer lg:drawer-open"> |         <div className="flex flex-row drawer lg:drawer-open"> | ||||||
|           <AppContextProvider> |           <AppContextProvider> | ||||||
| @@ -20,6 +22,7 @@ function App() { | |||||||
|           </AppContextProvider> |           </AppContextProvider> | ||||||
|         </div> |         </div> | ||||||
|       </HashRouter> |       </HashRouter> | ||||||
|  |     </ModalProvider> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										151
									
								
								tools/server/webui/src/components/ModalProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								tools/server/webui/src/components/ModalProvider.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | |||||||
|  | import React, { createContext, useState, useContext } from 'react'; | ||||||
|  |  | ||||||
|  | type ModalContextType = { | ||||||
|  |   showConfirm: (message: string) => Promise<boolean>; | ||||||
|  |   showPrompt: ( | ||||||
|  |     message: string, | ||||||
|  |     defaultValue?: string | ||||||
|  |   ) => Promise<string | undefined>; | ||||||
|  |   showAlert: (message: string) => Promise<void>; | ||||||
|  | }; | ||||||
|  | const ModalContext = createContext<ModalContextType>(null!); | ||||||
|  |  | ||||||
|  | interface ModalState<T> { | ||||||
|  |   isOpen: boolean; | ||||||
|  |   message: string; | ||||||
|  |   defaultValue?: string; | ||||||
|  |   resolve: ((value: T) => void) | null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function ModalProvider({ children }: { children: React.ReactNode }) { | ||||||
|  |   const [confirmState, setConfirmState] = useState<ModalState<boolean>>({ | ||||||
|  |     isOpen: false, | ||||||
|  |     message: '', | ||||||
|  |     resolve: null, | ||||||
|  |   }); | ||||||
|  |   const [promptState, setPromptState] = useState< | ||||||
|  |     ModalState<string | undefined> | ||||||
|  |   >({ isOpen: false, message: '', resolve: null }); | ||||||
|  |   const [alertState, setAlertState] = useState<ModalState<void>>({ | ||||||
|  |     isOpen: false, | ||||||
|  |     message: '', | ||||||
|  |     resolve: null, | ||||||
|  |   }); | ||||||
|  |   const inputRef = React.useRef<HTMLInputElement>(null); | ||||||
|  |  | ||||||
|  |   const showConfirm = (message: string): Promise<boolean> => { | ||||||
|  |     return new Promise((resolve) => { | ||||||
|  |       setConfirmState({ isOpen: true, message, resolve }); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const showPrompt = ( | ||||||
|  |     message: string, | ||||||
|  |     defaultValue?: string | ||||||
|  |   ): Promise<string | undefined> => { | ||||||
|  |     return new Promise((resolve) => { | ||||||
|  |       setPromptState({ isOpen: true, message, defaultValue, resolve }); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const showAlert = (message: string): Promise<void> => { | ||||||
|  |     return new Promise((resolve) => { | ||||||
|  |       setAlertState({ isOpen: true, message, resolve }); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleConfirm = (result: boolean) => { | ||||||
|  |     confirmState.resolve?.(result); | ||||||
|  |     setConfirmState({ isOpen: false, message: '', resolve: null }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handlePrompt = (result?: string) => { | ||||||
|  |     promptState.resolve?.(result); | ||||||
|  |     setPromptState({ isOpen: false, message: '', resolve: null }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleAlertClose = () => { | ||||||
|  |     alertState.resolve?.(); | ||||||
|  |     setAlertState({ isOpen: false, message: '', resolve: null }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <ModalContext.Provider value={{ showConfirm, showPrompt, showAlert }}> | ||||||
|  |       {children} | ||||||
|  |  | ||||||
|  |       {/* Confirm Modal */} | ||||||
|  |       {confirmState.isOpen && ( | ||||||
|  |         <dialog className="modal modal-open z-[1100]"> | ||||||
|  |           <div className="modal-box"> | ||||||
|  |             <h3 className="font-bold text-lg">{confirmState.message}</h3> | ||||||
|  |             <div className="modal-action"> | ||||||
|  |               <button | ||||||
|  |                 className="btn btn-ghost" | ||||||
|  |                 onClick={() => handleConfirm(false)} | ||||||
|  |               > | ||||||
|  |                 Cancel | ||||||
|  |               </button> | ||||||
|  |               <button | ||||||
|  |                 className="btn btn-error" | ||||||
|  |                 onClick={() => handleConfirm(true)} | ||||||
|  |               > | ||||||
|  |                 Confirm | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </dialog> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {/* Prompt Modal */} | ||||||
|  |       {promptState.isOpen && ( | ||||||
|  |         <dialog className="modal modal-open z-[1100]"> | ||||||
|  |           <div className="modal-box"> | ||||||
|  |             <h3 className="font-bold text-lg">{promptState.message}</h3> | ||||||
|  |             <input | ||||||
|  |               type="text" | ||||||
|  |               className="input input-bordered w-full mt-2" | ||||||
|  |               defaultValue={promptState.defaultValue} | ||||||
|  |               ref={inputRef} | ||||||
|  |               onKeyDown={(e) => { | ||||||
|  |                 if (e.key === 'Enter') { | ||||||
|  |                   handlePrompt((e.target as HTMLInputElement).value); | ||||||
|  |                 } | ||||||
|  |               }} | ||||||
|  |             /> | ||||||
|  |             <div className="modal-action"> | ||||||
|  |               <button className="btn btn-ghost" onClick={() => handlePrompt()}> | ||||||
|  |                 Cancel | ||||||
|  |               </button> | ||||||
|  |               <button | ||||||
|  |                 className="btn btn-primary" | ||||||
|  |                 onClick={() => handlePrompt(inputRef.current?.value)} | ||||||
|  |               > | ||||||
|  |                 Submit | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </dialog> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {/* Alert Modal */} | ||||||
|  |       {alertState.isOpen && ( | ||||||
|  |         <dialog className="modal modal-open z-[1100]"> | ||||||
|  |           <div className="modal-box"> | ||||||
|  |             <h3 className="font-bold text-lg">{alertState.message}</h3> | ||||||
|  |             <div className="modal-action"> | ||||||
|  |               <button className="btn" onClick={handleAlertClose}> | ||||||
|  |                 OK | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </dialog> | ||||||
|  |       )} | ||||||
|  |     </ModalContext.Provider> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function useModals() { | ||||||
|  |   const context = useContext(ModalContext); | ||||||
|  |   if (!context) throw new Error('useModals must be used within ModalProvider'); | ||||||
|  |   return context; | ||||||
|  | } | ||||||
| @@ -13,6 +13,7 @@ import { | |||||||
|   SquaresPlusIcon, |   SquaresPlusIcon, | ||||||
| } from '@heroicons/react/24/outline'; | } from '@heroicons/react/24/outline'; | ||||||
| import { OpenInNewTab } from '../utils/common'; | import { OpenInNewTab } from '../utils/common'; | ||||||
|  | import { useModals } from './ModalProvider'; | ||||||
|  |  | ||||||
| type SettKey = keyof typeof CONFIG_DEFAULT; | type SettKey = keyof typeof CONFIG_DEFAULT; | ||||||
|  |  | ||||||
| @@ -282,14 +283,15 @@ export default function SettingDialog({ | |||||||
|   const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>( |   const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>( | ||||||
|     JSON.parse(JSON.stringify(config)) |     JSON.parse(JSON.stringify(config)) | ||||||
|   ); |   ); | ||||||
|  |   const { showConfirm, showAlert } = useModals(); | ||||||
|  |  | ||||||
|   const resetConfig = () => { |   const resetConfig = async () => { | ||||||
|     if (window.confirm('Are you sure you want to reset all settings?')) { |     if (await showConfirm('Are you sure you want to reset all settings?')) { | ||||||
|       setLocalConfig(CONFIG_DEFAULT); |       setLocalConfig(CONFIG_DEFAULT); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleSave = () => { |   const handleSave = async () => { | ||||||
|     // copy the local config to prevent direct mutation |     // copy the local config to prevent direct mutation | ||||||
|     const newConfig: typeof CONFIG_DEFAULT = JSON.parse( |     const newConfig: typeof CONFIG_DEFAULT = JSON.parse( | ||||||
|       JSON.stringify(localConfig) |       JSON.stringify(localConfig) | ||||||
| @@ -302,14 +304,14 @@ export default function SettingDialog({ | |||||||
|       const mustBeNumeric = isNumeric(CONFIG_DEFAULT[key as SettKey]); |       const mustBeNumeric = isNumeric(CONFIG_DEFAULT[key as SettKey]); | ||||||
|       if (mustBeString) { |       if (mustBeString) { | ||||||
|         if (!isString(value)) { |         if (!isString(value)) { | ||||||
|           alert(`Value for ${key} must be string`); |           await showAlert(`Value for ${key} must be string`); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|       } else if (mustBeNumeric) { |       } else if (mustBeNumeric) { | ||||||
|         const trimmedValue = value.toString().trim(); |         const trimmedValue = value.toString().trim(); | ||||||
|         const numVal = Number(trimmedValue); |         const numVal = Number(trimmedValue); | ||||||
|         if (isNaN(numVal) || !isNumeric(numVal) || trimmedValue.length === 0) { |         if (isNaN(numVal) || !isNumeric(numVal) || trimmedValue.length === 0) { | ||||||
|           alert(`Value for ${key} must be numeric`); |           await showAlert(`Value for ${key} must be numeric`); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|         // force conversion to number |         // force conversion to number | ||||||
| @@ -317,7 +319,7 @@ export default function SettingDialog({ | |||||||
|         newConfig[key] = numVal; |         newConfig[key] = numVal; | ||||||
|       } else if (mustBeBoolean) { |       } else if (mustBeBoolean) { | ||||||
|         if (!isBoolean(value)) { |         if (!isBoolean(value)) { | ||||||
|           alert(`Value for ${key} must be boolean`); |           await showAlert(`Value for ${key} must be boolean`); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ import { | |||||||
| import { BtnWithTooltips } from '../utils/common'; | import { BtnWithTooltips } from '../utils/common'; | ||||||
| import { useAppContext } from '../utils/app.context'; | import { useAppContext } from '../utils/app.context'; | ||||||
| import toast from 'react-hot-toast'; | import toast from 'react-hot-toast'; | ||||||
|  | import { useModals } from './ModalProvider'; | ||||||
|  |  | ||||||
| export default function Sidebar() { | export default function Sidebar() { | ||||||
|   const params = useParams(); |   const params = useParams(); | ||||||
| @@ -38,6 +39,7 @@ export default function Sidebar() { | |||||||
|       StorageUtils.offConversationChanged(handleConversationChange); |       StorageUtils.offConversationChanged(handleConversationChange); | ||||||
|     }; |     }; | ||||||
|   }, []); |   }, []); | ||||||
|  |   const { showConfirm, showPrompt } = useModals(); | ||||||
|  |  | ||||||
|   const groupedConv = useMemo( |   const groupedConv = useMemo( | ||||||
|     () => groupConversationsByDate(conversations), |     () => groupConversationsByDate(conversations), | ||||||
| @@ -130,7 +132,7 @@ export default function Sidebar() { | |||||||
|                   onSelect={() => { |                   onSelect={() => { | ||||||
|                     navigate(`/chat/${conv.id}`); |                     navigate(`/chat/${conv.id}`); | ||||||
|                   }} |                   }} | ||||||
|                   onDelete={() => { |                   onDelete={async () => { | ||||||
|                     if (isGenerating(conv.id)) { |                     if (isGenerating(conv.id)) { | ||||||
|                       toast.error( |                       toast.error( | ||||||
|                         'Cannot delete conversation while generating' |                         'Cannot delete conversation while generating' | ||||||
| @@ -138,7 +140,7 @@ export default function Sidebar() { | |||||||
|                       return; |                       return; | ||||||
|                     } |                     } | ||||||
|                     if ( |                     if ( | ||||||
|                       window.confirm( |                       await showConfirm( | ||||||
|                         'Are you sure to delete this conversation?' |                         'Are you sure to delete this conversation?' | ||||||
|                       ) |                       ) | ||||||
|                     ) { |                     ) { | ||||||
| @@ -167,14 +169,14 @@ export default function Sidebar() { | |||||||
|                     document.body.removeChild(a); |                     document.body.removeChild(a); | ||||||
|                     URL.revokeObjectURL(url); |                     URL.revokeObjectURL(url); | ||||||
|                   }} |                   }} | ||||||
|                   onRename={() => { |                   onRename={async () => { | ||||||
|                     if (isGenerating(conv.id)) { |                     if (isGenerating(conv.id)) { | ||||||
|                       toast.error( |                       toast.error( | ||||||
|                         'Cannot rename conversation while generating' |                         'Cannot rename conversation while generating' | ||||||
|                       ); |                       ); | ||||||
|                       return; |                       return; | ||||||
|                     } |                     } | ||||||
|                     const newName = window.prompt( |                     const newName = await showPrompt( | ||||||
|                       'Enter new name for the conversation', |                       'Enter new name for the conversation', | ||||||
|                       conv.name |                       conv.name | ||||||
|                     ); |                     ); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 igardev
					igardev