SkateSpots/src/components/chat/TranslateMessage.tsx
SkateSpots e85b9aacb6 feat(chat): all-language on-device translation (M2M100 + Basque Opus)
- 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.
2026-10-27 13:06:04 +07:00

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