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:
igardev 2025-05-31 12:56:08 +03:00 committed by GitHub
parent 3f55f781f1
commit c7e0a2054b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 180 additions and 22 deletions

Binary file not shown.

View file

@ -5,21 +5,24 @@ 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 (
<HashRouter> <ModalProvider>
<div className="flex flex-row drawer lg:drawer-open"> <HashRouter>
<AppContextProvider> <div className="flex flex-row drawer lg:drawer-open">
<Routes> <AppContextProvider>
<Route element={<AppLayout />}> <Routes>
<Route path="/chat/:convId" element={<ChatScreen />} /> <Route element={<AppLayout />}>
<Route path="*" element={<ChatScreen />} /> <Route path="/chat/:convId" element={<ChatScreen />} />
</Route> <Route path="*" element={<ChatScreen />} />
</Routes> </Route>
</AppContextProvider> </Routes>
</div> </AppContextProvider>
</HashRouter> </div>
</HashRouter>
</ModalProvider>
); );
} }

View 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;
}

View file

@ -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 {

View file

@ -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
); );