Spaces:
Runtime error
Runtime error
| /* global React, ReactDOM, MaterialUI */ | |
| const { | |
| AppBar, | |
| Toolbar, | |
| Box, | |
| Paper, | |
| IconButton, | |
| Tabs, | |
| Tab, | |
| Select, | |
| MenuItem, | |
| Button, | |
| CircularProgress, | |
| Tooltip, | |
| Snackbar, | |
| Alert, | |
| Divider, | |
| Typography, | |
| ThemeProvider, | |
| createTheme, | |
| CssBaseline, | |
| } = MaterialUI; | |
| const { useState, useEffect, useMemo, useRef, useCallback } = React; | |
| const theme = createTheme({ | |
| palette: { | |
| mode: "light", | |
| primary: { main: "#1a73e8" }, | |
| background: { default: "#ffffff", paper: "#ffffff" }, | |
| text: { primary: "#202124", secondary: "#5f6368" }, | |
| divider: "#dadce0", | |
| }, | |
| typography: { | |
| fontFamily: | |
| '"Google Sans", "Roboto", "Helvetica", "Arial", sans-serif', | |
| }, | |
| shape: { borderRadius: 8 }, | |
| components: { | |
| MuiButton: { styleOverrides: { root: { textTransform: "none", fontWeight: 500 } } }, | |
| MuiTab: { | |
| styleOverrides: { | |
| root: { | |
| textTransform: "none", | |
| fontSize: 14, | |
| fontWeight: 500, | |
| minHeight: 48, | |
| minWidth: 0, | |
| padding: "12px 16px", | |
| color: "#5f6368", | |
| "&.Mui-selected": { color: "#1a73e8" }, | |
| }, | |
| }, | |
| }, | |
| MuiTabs: { styleOverrides: { indicator: { height: 3, borderRadius: "3px 3px 0 0" } } }, | |
| }, | |
| }); | |
| const MI = ({ name, sx }) => ( | |
| <span className="material-symbols-outlined" style={sx}>{name}</span> | |
| ); | |
| const POPULAR = ["en", "zh", "es", "fr"]; | |
| function pickPopular(languages, includeAuto) { | |
| const codes = (includeAuto ? ["auto"] : []).concat(POPULAR); | |
| return codes.filter((c) => languages[c]); | |
| } | |
| function LanguageTabs({ languages, value, onChange, includeAuto }) { | |
| const popular = pickPopular(languages, includeAuto); | |
| const isPopular = popular.includes(value); | |
| const tabValue = isPopular ? value : "__more__"; | |
| const handleTab = (_, newValue) => { | |
| if (newValue !== "__more__") onChange(newValue); | |
| }; | |
| const otherEntries = Object.entries(languages).filter( | |
| ([code]) => !popular.includes(code) && (includeAuto || code !== "auto"), | |
| ); | |
| return ( | |
| <Box sx={{ display: "flex", alignItems: "center", flex: 1, minWidth: 0 }}> | |
| <Tabs | |
| value={tabValue} | |
| onChange={handleTab} | |
| variant="scrollable" | |
| scrollButtons={false} | |
| sx={{ minHeight: 48, flex: 1, minWidth: 0 }} | |
| > | |
| {popular.map((code) => ( | |
| <Tab key={code} value={code} label={languages[code]} /> | |
| ))} | |
| <Tab | |
| value="__more__" | |
| sx={{ p: 0, minWidth: 0 }} | |
| label={ | |
| <Select | |
| value={isPopular ? "" : value} | |
| onChange={(e) => onChange(e.target.value)} | |
| displayEmpty | |
| variant="standard" | |
| disableUnderline | |
| renderValue={(selected) => { | |
| const label = selected ? languages[selected] : "More"; | |
| return ( | |
| <Box sx={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 0.5, | |
| color: isPopular ? "#5f6368" : "#1a73e8", | |
| fontSize: 14, | |
| fontWeight: 500, | |
| px: 2, | |
| py: 1.5, | |
| textTransform: "none", | |
| }}> | |
| {label} | |
| <MI name="arrow_drop_down" /> | |
| </Box> | |
| ); | |
| }} | |
| sx={{ | |
| "& .MuiSelect-select": { p: 0, pr: "0 !important" }, | |
| "& .MuiSelect-icon": { display: "none" }, | |
| }} | |
| MenuProps={{ | |
| PaperProps: { sx: { maxHeight: 360, mt: 0.5, borderRadius: 2 } }, | |
| }} | |
| > | |
| {otherEntries.map(([code, name]) => ( | |
| <MenuItem key={code} value={code}>{name}</MenuItem> | |
| ))} | |
| </Select> | |
| } | |
| /> | |
| </Tabs> | |
| </Box> | |
| ); | |
| } | |
| function App() { | |
| const [languages, setLanguages] = useState({}); | |
| const [maxInputChars, setMaxInputChars] = useState(6000); | |
| const [modelId, setModelId] = useState(""); | |
| const [source, setSource] = useState("auto"); | |
| const [target, setTarget] = useState("en"); | |
| const [inputText, setInputText] = useState(""); | |
| const [translation, setTranslation] = useState(""); | |
| const [transliteration, setTransliteration] = useState(""); | |
| const [transliterationLabel, setTransliterationLabel] = useState(""); | |
| const [cached, setCached] = useState(false); | |
| const [elapsed, setElapsed] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [toast, setToast] = useState(null); | |
| const debounceRef = useRef(null); | |
| useEffect(() => { | |
| fetch("/api/config") | |
| .then((r) => { | |
| if (!r.ok) throw new Error("Could not load app config."); | |
| return r.json(); | |
| }) | |
| .then((c) => { | |
| setLanguages(c.languages); | |
| setMaxInputChars(c.max_input_chars); | |
| setModelId(c.model_id); | |
| }) | |
| .catch((e) => setError(e.message)); | |
| }, []); | |
| const runTranslate = useCallback( | |
| async (text, src, tgt) => { | |
| const value = text.trim(); | |
| if (!value) { | |
| setTranslation(""); | |
| setTransliteration(""); | |
| setTransliterationLabel(""); | |
| setCached(false); | |
| setElapsed(null); | |
| setLoading(false); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(""); | |
| try { | |
| const response = await fetch("/api/translate", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| text: value, | |
| source_language: src, | |
| target_language: tgt, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Translation failed."); | |
| setTranslation(data.translation); | |
| setTransliteration(data.transliteration || ""); | |
| setTransliterationLabel(data.transliteration_label || ""); | |
| setCached(Boolean(data.cached)); | |
| setElapsed(data.elapsed_ms); | |
| } catch (e) { | |
| setTranslation(""); | |
| setTransliteration(""); | |
| setTransliterationLabel(""); | |
| setCached(false); | |
| setElapsed(null); | |
| setError(e.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, | |
| [], | |
| ); | |
| useEffect(() => { | |
| if (debounceRef.current) clearTimeout(debounceRef.current); | |
| if (!inputText.trim()) { | |
| setTranslation(""); | |
| setTransliteration(""); | |
| setTransliterationLabel(""); | |
| setCached(false); | |
| setElapsed(null); | |
| setLoading(false); | |
| return; | |
| } | |
| debounceRef.current = setTimeout(() => { | |
| runTranslate(inputText, source, target); | |
| }, 700); | |
| return () => debounceRef.current && clearTimeout(debounceRef.current); | |
| }, [inputText, source, target, runTranslate]); | |
| const swap = () => { | |
| if (source === "auto") return; | |
| const oldSource = source; | |
| setSource(target); | |
| setTarget(oldSource); | |
| if (translation) { | |
| setInputText(translation); | |
| setTranslation(""); | |
| setTransliteration(""); | |
| setTransliterationLabel(""); | |
| setCached(false); | |
| } | |
| }; | |
| const charCount = inputText.length; | |
| const overLimit = charCount > maxInputChars; | |
| const copy = async () => { | |
| if (!translation) return; | |
| try { | |
| await navigator.clipboard.writeText(translation); | |
| setToast({ severity: "success", message: "Copied translation" }); | |
| } catch { | |
| setToast({ severity: "error", message: "Copy failed" }); | |
| } | |
| }; | |
| const speak = (text, lang) => { | |
| if (!text || !window.speechSynthesis) return; | |
| const utter = new SpeechSynthesisUtterance(text); | |
| if (lang && lang !== "auto") utter.lang = lang; | |
| window.speechSynthesis.cancel(); | |
| window.speechSynthesis.speak(utter); | |
| }; | |
| return ( | |
| <ThemeProvider theme={theme}> | |
| <CssBaseline /> | |
| <Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh", bgcolor: "#fff" }}> | |
| <AppBar | |
| position="static" | |
| color="transparent" | |
| elevation={0} | |
| sx={{ borderBottom: "1px solid #ebebeb" }} | |
| > | |
| <Toolbar sx={{ minHeight: 64, px: { xs: 2, md: 3 } }}> | |
| <Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}> | |
| <Box sx={{ | |
| width: 36, height: 36, borderRadius: "50%", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| color: "#1a73e8", | |
| }}> | |
| <MI name="translate" sx={{ fontSize: 28 }} /> | |
| </Box> | |
| <Typography | |
| sx={{ | |
| color: "#5f6368", | |
| fontSize: 22, | |
| letterSpacing: 0.2, | |
| fontFamily: '"Google Sans", "Product Sans", Roboto, sans-serif', | |
| }} | |
| > | |
| Translate | |
| </Typography> | |
| </Box> | |
| <Box sx={{ flex: 1 }} /> | |
| <Tooltip title={modelId || "Model"}> | |
| <Button | |
| href="https://huggingface.co/AngelSlim/Hy-MT1.5-1.8B-1.25bit" | |
| target="_blank" | |
| rel="noreferrer" | |
| sx={{ | |
| color: "#5f6368", | |
| borderRadius: 999, | |
| px: 2, | |
| "&:hover": { bgcolor: "#f1f3f4" }, | |
| }} | |
| startIcon={<MI name="open_in_new" sx={{ fontSize: 18 }} />} | |
| > | |
| Hugging Face | |
| </Button> | |
| </Tooltip> | |
| </Toolbar> | |
| </AppBar> | |
| <Box sx={{ | |
| flex: 1, | |
| width: "100%", | |
| maxWidth: 1280, | |
| mx: "auto", | |
| px: { xs: 1.5, md: 3 }, | |
| py: { xs: 2, md: 3 }, | |
| }}> | |
| <Paper | |
| elevation={0} | |
| sx={{ | |
| border: "1px solid #dadce0", | |
| borderRadius: 2, | |
| overflow: "hidden", | |
| boxShadow: "0 1px 3px rgba(60,64,67,0.08)", | |
| }} | |
| > | |
| <Box sx={{ | |
| display: "grid", | |
| gridTemplateColumns: "1fr auto 1fr", | |
| alignItems: "center", | |
| borderBottom: "1px solid #dadce0", | |
| bgcolor: "#fff", | |
| }}> | |
| <LanguageTabs | |
| languages={languages} | |
| value={source} | |
| onChange={setSource} | |
| includeAuto | |
| /> | |
| <Tooltip title="Swap languages"> | |
| <span> | |
| <IconButton | |
| onClick={swap} | |
| disabled={source === "auto"} | |
| sx={{ | |
| mx: 1, | |
| color: "#5f6368", | |
| border: "1px solid #dadce0", | |
| width: 40, | |
| height: 40, | |
| "&:hover": { bgcolor: "#f1f3f4" }, | |
| }} | |
| > | |
| <MI name="swap_horiz" /> | |
| </IconButton> | |
| </span> | |
| </Tooltip> | |
| <LanguageTabs | |
| languages={languages} | |
| value={target} | |
| onChange={setTarget} | |
| includeAuto={false} | |
| /> | |
| </Box> | |
| <Box sx={{ | |
| display: "grid", | |
| gridTemplateColumns: { xs: "1fr", md: "1fr 1px 1fr" }, | |
| minHeight: { xs: "auto", md: 360 }, | |
| }}> | |
| <Box sx={{ display: "flex", flexDirection: "column", position: "relative" }}> | |
| <Box sx={{ position: "relative", flex: 1, display: "flex" }}> | |
| <textarea | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| placeholder="Enter text" | |
| spellCheck="true" | |
| style={{ | |
| width: "100%", | |
| minHeight: 260, | |
| flex: 1, | |
| padding: "20px 56px 56px 24px", | |
| border: "none", | |
| outline: "none", | |
| resize: "none", | |
| fontSize: 24, | |
| lineHeight: 1.45, | |
| color: "#202124", | |
| background: "transparent", | |
| }} | |
| /> | |
| {inputText && ( | |
| <IconButton | |
| onClick={() => setInputText("")} | |
| size="small" | |
| sx={{ | |
| position: "absolute", | |
| top: 8, | |
| right: 8, | |
| color: "#5f6368", | |
| "&:hover": { bgcolor: "#f1f3f4" }, | |
| }} | |
| aria-label="Clear" | |
| > | |
| <MI name="close" sx={{ fontSize: 20 }} /> | |
| </IconButton> | |
| )} | |
| </Box> | |
| <Box sx={{ | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| px: 1, | |
| py: 1, | |
| minHeight: 48, | |
| }}> | |
| <Box sx={{ display: "flex", alignItems: "center" }}> | |
| <Tooltip title="Listen"> | |
| <span> | |
| <IconButton | |
| onClick={() => speak(inputText, source === "auto" ? null : source)} | |
| disabled={!inputText} | |
| size="small" | |
| sx={{ color: "#5f6368" }} | |
| > | |
| <MI name="volume_up" /> | |
| </IconButton> | |
| </span> | |
| </Tooltip> | |
| </Box> | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| color: overLimit ? "#d93025" : "#5f6368", | |
| pr: 1, | |
| fontSize: 13, | |
| }} | |
| > | |
| {charCount} / {maxInputChars} | |
| </Typography> | |
| </Box> | |
| </Box> | |
| <Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" } }} /> | |
| <Divider sx={{ display: { xs: "block", md: "none" } }} /> | |
| <Box sx={{ | |
| display: "flex", | |
| flexDirection: "column", | |
| bgcolor: "#f8f9fa", | |
| position: "relative", | |
| }}> | |
| <Box sx={{ | |
| flex: 1, | |
| minHeight: 260, | |
| padding: "20px 24px 56px 24px", | |
| fontSize: 24, | |
| lineHeight: 1.45, | |
| color: translation ? "#202124" : "#80868b", | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| position: "relative", | |
| }}> | |
| {loading && !translation && ( | |
| <Box sx={{ | |
| position: "absolute", | |
| top: 24, | |
| left: 24, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 1.5, | |
| color: "#5f6368", | |
| fontSize: 16, | |
| }}> | |
| <CircularProgress size={18} thickness={5} /> | |
| <span>Translating…</span> | |
| </Box> | |
| )} | |
| {!loading && !translation && !error && ( | |
| <span style={{ fontSize: 24 }}>Translation</span> | |
| )} | |
| {error && ( | |
| <Box sx={{ color: "#d93025", fontSize: 16 }}>{error}</Box> | |
| )} | |
| {translation} | |
| {transliteration && ( | |
| <Box sx={{ mt: 2, pt: 2, borderTop: "1px solid #e8eaed" }}> | |
| {transliterationLabel && ( | |
| <Typography | |
| variant="caption" | |
| sx={{ | |
| display: "block", | |
| color: "#80868b", | |
| fontSize: 12, | |
| textTransform: "uppercase", | |
| letterSpacing: 0.6, | |
| mb: 0.5, | |
| }} | |
| > | |
| {transliterationLabel} | |
| </Typography> | |
| )} | |
| <Box | |
| sx={{ | |
| color: "#5f6368", | |
| fontSize: 18, | |
| lineHeight: 1.45, | |
| fontStyle: "italic", | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {transliteration} | |
| </Box> | |
| </Box> | |
| )} | |
| </Box> | |
| <Box sx={{ | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| px: 1, | |
| py: 1, | |
| minHeight: 48, | |
| }}> | |
| <Box sx={{ display: "flex", alignItems: "center" }}> | |
| <Tooltip title="Listen"> | |
| <span> | |
| <IconButton | |
| onClick={() => speak(translation, target)} | |
| disabled={!translation} | |
| size="small" | |
| sx={{ color: "#5f6368" }} | |
| > | |
| <MI name="volume_up" /> | |
| </IconButton> | |
| </span> | |
| </Tooltip> | |
| <Tooltip title="Copy translation"> | |
| <span> | |
| <IconButton | |
| onClick={copy} | |
| disabled={!translation} | |
| size="small" | |
| sx={{ color: "#5f6368" }} | |
| > | |
| <MI name="content_copy" sx={{ fontSize: 20 }} /> | |
| </IconButton> | |
| </span> | |
| </Tooltip> | |
| </Box> | |
| <Box sx={{ pr: 1, display: "flex", alignItems: "center", gap: 1 }}> | |
| {cached && ( | |
| <Tooltip title="Served from local SQLite cache"> | |
| <Box | |
| sx={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: 0.5, | |
| color: "#188038", | |
| bgcolor: "#e6f4ea", | |
| px: 1, | |
| py: 0.25, | |
| borderRadius: 999, | |
| fontSize: 12, | |
| fontWeight: 500, | |
| }} | |
| > | |
| <MI name="bolt" sx={{ fontSize: 14 }} /> | |
| Cached | |
| </Box> | |
| </Tooltip> | |
| )} | |
| {elapsed !== null && ( | |
| <Typography variant="caption" sx={{ color: "#5f6368", fontSize: 13 }}> | |
| {elapsed} ms | |
| </Typography> | |
| )} | |
| </Box> | |
| </Box> | |
| </Box> | |
| </Box> | |
| </Paper> | |
| <Box sx={{ | |
| mt: 2, | |
| display: "flex", | |
| justifyContent: "center", | |
| color: "#80868b", | |
| fontSize: 12, | |
| }}> | |
| <Typography variant="caption"> | |
| Powered by {modelId || "AngelSlim"} | |
| </Typography> | |
| </Box> | |
| </Box> | |
| <Snackbar | |
| open={Boolean(toast)} | |
| autoHideDuration={2200} | |
| onClose={() => setToast(null)} | |
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | |
| > | |
| {toast ? ( | |
| <Alert severity={toast.severity} variant="filled" onClose={() => setToast(null)}> | |
| {toast.message} | |
| </Alert> | |
| ) : undefined} | |
| </Snackbar> | |
| </Box> | |
| </ThemeProvider> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById("root")); | |
| root.render(<App />); | |