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:
parent
3f55f781f1
commit
c7e0a2054b
5 changed files with 180 additions and 22 deletions
Binary file not shown.
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue