- M2M100 418M (MIT, 617 MB q8) covers 23 app languages; Basque uses converted Opus eu-en/en-eu q8 packs; all self-hosted under relay.skate-spots.org/models. - Worker uses M2M100 directly and pivots Basque through English; UI targets all languages; old pilot restriction/note removed; memory-only + guard unchanged. - Verified en<->es, ca<->gl, hi<->en from the host. Basque packs are converted and hosted; the final MarianTokenizer/ONNX runtime check is still pending. - 138 tests, 24 locales, build and smoke green. SpotChat deliberately skipped.
161 lines
4.9 KiB
TypeScript
161 lines
4.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import type { Language } from '@/contexts/language';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import { detectLanguage } from '@/lib/detectLanguage';
|
|
import { isTranslationPairSupported, translateText } from '@/lib/translation';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const SOURCE_NAMES: Record<Language, string> = {
|
|
en: 'English',
|
|
es: 'Spanish',
|
|
ca: 'Catalan',
|
|
eu: 'Basque',
|
|
gl: 'Galician',
|
|
hi: 'Hindi',
|
|
th: 'Thai',
|
|
ja: 'Japanese',
|
|
pt: 'Portuguese',
|
|
'pt-PT': 'Portuguese (Portugal)',
|
|
fr: 'French',
|
|
de: 'German',
|
|
it: 'Italian',
|
|
nl: 'Dutch',
|
|
pl: 'Polish',
|
|
ru: 'Russian',
|
|
ko: 'Korean',
|
|
'ko-KP': 'Korean (North)',
|
|
'zh-CN': 'Chinese (Simplified)',
|
|
'zh-TW': 'Chinese (Traditional)',
|
|
uk: 'Ukrainian',
|
|
tr: 'Turkish',
|
|
vi: 'Vietnamese',
|
|
id: 'Indonesian',
|
|
sv: 'Swedish',
|
|
cs: 'Czech',
|
|
ro: 'Romanian',
|
|
el: 'Greek',
|
|
};
|
|
|
|
type State = 'idle' | 'detecting' | 'loading' | 'done' | 'error' | 'same';
|
|
|
|
interface TranslateMessageProps {
|
|
text: string;
|
|
messageId: string;
|
|
isOwn?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Message body with on-device translation.
|
|
*
|
|
* English ↔ Spanish is the pilot. The model runs in a worker; the message text
|
|
* is only sent to that local worker (never to a server). Translations are kept
|
|
* in memory and cleared on reload/logout.
|
|
*/
|
|
export function TranslateMessage({ text, messageId, isOwn = false, className }: TranslateMessageProps) {
|
|
const { language, t } = useTranslation();
|
|
const [autoTranslate] = useLocalStorage<boolean>('skatespots:auto-translate', false);
|
|
const [state, setState] = useState<State>('idle');
|
|
const [translated, setTranslated] = useState<string | null>(null);
|
|
const [source, setSource] = useState<Language | null>(null);
|
|
const [showOriginal, setShowOriginal] = useState(false);
|
|
const [progress, setProgress] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleTranslate = useCallback(async () => {
|
|
if (!text.trim()) return;
|
|
setState('detecting');
|
|
setError(null);
|
|
setProgress(null);
|
|
setShowOriginal(false);
|
|
|
|
const detected = await detectLanguage(text);
|
|
if (!detected.language) {
|
|
setState('error');
|
|
setError(t('translateUi.failed'));
|
|
return;
|
|
}
|
|
setSource(detected.language);
|
|
|
|
if (detected.language === language) {
|
|
setState('same');
|
|
return;
|
|
}
|
|
if (!isTranslationPairSupported(detected.language, language)) {
|
|
setState('error');
|
|
setError(t('translateUi.unsupported'));
|
|
return;
|
|
}
|
|
|
|
setState('loading');
|
|
try {
|
|
const result = await translateText({
|
|
id: messageId,
|
|
text,
|
|
source: detected.language,
|
|
target: language,
|
|
onProgress: (p) => setProgress(typeof p.progress === 'number' ? p.progress : null),
|
|
});
|
|
setTranslated(result);
|
|
setState('done');
|
|
} catch {
|
|
setState('error');
|
|
setError(t('translateUi.failed'));
|
|
}
|
|
}, [text, messageId, language, t]);
|
|
|
|
useEffect(() => {
|
|
if (!autoTranslate || isOwn) return;
|
|
if (state !== 'idle' || !text.trim()) return;
|
|
void handleTranslate();
|
|
}, [autoTranslate, isOwn, state, text, handleTranslate]);
|
|
|
|
const body = state === 'done' && !showOriginal ? translated ?? text : text;
|
|
|
|
return (
|
|
<div className={cn('space-y-1', className)}>
|
|
<p className="whitespace-pre-wrap break-words">{body}</p>
|
|
{!isOwn && (
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] opacity-75">
|
|
{state === 'idle' && (
|
|
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">
|
|
{t('translateUi.translate')}
|
|
</button>
|
|
)}
|
|
|
|
{(state === 'detecting' || state === 'loading') && (
|
|
<span>
|
|
{t('translateUi.translating')}
|
|
{progress !== null ? ` ${Math.round(progress)}%` : '…'}
|
|
</span>
|
|
)}
|
|
|
|
{state === 'done' && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowOriginal((value) => !value)}
|
|
className="underline hover:no-underline"
|
|
>
|
|
{showOriginal ? t('translateUi.showTranslation') : t('translateUi.showOriginal')}
|
|
</button>
|
|
{source && (
|
|
<span>{t('translateUi.translatedFrom', { language: SOURCE_NAMES[source] })}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{state === 'error' && (
|
|
<>
|
|
<span>{error ?? t('translateUi.failed')}</span>
|
|
<button type="button" onClick={() => void handleTranslate()} className="underline hover:no-underline">
|
|
{t('translateUi.translate')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|