Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,4 @@ Premul
eurollm
doclayout
netp
Uyghur
7 changes: 7 additions & 0 deletions apps/nlp/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export default function Layout() {
title: 'Privacy Filter',
}}
/>
<Drawer.Screen
name="translation/index"
options={{
drawerLabel: 'Translation',
title: 'Translation',
}}
/>
<Drawer.Screen
name="llm/index"
options={{
Expand Down
3 changes: 3 additions & 0 deletions apps/nlp/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export default function Home() {
<TouchableOpacity style={styles.button} onPress={() => router.navigate('privacy-filter/')}>
<Text style={styles.buttonText}>Privacy Filter</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.navigate('translation/')}>
<Text style={styles.buttonText}>Translation</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.navigate('llm/')}>
<Text style={styles.buttonText}>LLM</Text>
</TouchableOpacity>
Expand Down
308 changes: 308 additions & 0 deletions apps/nlp/app/translation/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
StyleSheet,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import {
useTranslator,
models,
type TranslationLanguages,
type TranslatorModel,
} from 'react-native-executorch';
import ScreenWrapper from '../../components/ScreenWrapper';
import { ModelStatus } from '../../components/ModelStatus';
import { Button } from '../../components/Button';
import { theme } from '../../theme';

/* cspell:disable */
const SAMPLES: Record<string, string> = {
en: 'The weather is beautiful today. Shall we go for a walk in the park?\nI will bring something to drink.',
de: 'Das Wetter ist heute wunderschön. Sollen wir im Park spazieren gehen?\nIch bringe etwas zu trinken mit.',
es: 'Hoy hace un tiempo precioso. ¿Vamos a dar un paseo por el parque?\nYo llevaré algo de beber.',
fr: 'Il fait très beau aujourd’hui. On va se promener dans le parc ?\nJ’apporterai quelque chose à boire.',
pl: 'Dzisiaj jest piękna pogoda. Może pójdziemy na spacer do parku?\nPrzyniosę coś do picia.',
zh: '今天天气真好。我们去公园散步好吗?\n我会带点喝的。',
ja: '今日はとても良い天気です。公園に散歩に行きませんか?\n飲み物を持っていきます。',
};
/* cspell:enable */

type Pair = { label: string; languages: TranslationLanguages };
const pair = (sourceLanguage: string, targetLanguage: string): Pair => ({
label: `${sourceLanguage.toUpperCase()} → ${targetLanguage.toUpperCase()}`,
languages: { sourceLanguage, targetLanguage },
});

// Opus-MT ships one model per pair; EuroLLM and Hy-MT2 translate every pair with one.
const OPUS_MT: { pair: Pair; model: TranslatorModel }[] = [
{ pair: pair('en', 'de'), model: models.translation.OPUS_MT_EN_DE.DEFAULT },
{ pair: pair('de', 'en'), model: models.translation.OPUS_MT_DE_EN.DEFAULT },
{ pair: pair('en', 'es'), model: models.translation.OPUS_MT_EN_ES.DEFAULT },
{ pair: pair('es', 'en'), model: models.translation.OPUS_MT_ES_EN.DEFAULT },
{ pair: pair('en', 'fr'), model: models.translation.OPUS_MT_EN_FR.DEFAULT },
{ pair: pair('fr', 'en'), model: models.translation.OPUS_MT_FR_EN.DEFAULT },
];
const EUROLLM_PAIRS = [...OPUS_MT.map((m) => m.pair), pair('en', 'pl'), pair('pl', 'en')];
const HY_MT2_PAIRS = [
...EUROLLM_PAIRS,
pair('en', 'zh'),
pair('zh', 'en'),
pair('en', 'ja'),
pair('ja', 'en'),
];

const FAMILIES = [
{
label: 'Opus-MT',
description:
'One small model per language pair. Translates a sentence at a time and streams the result as it is decoded.',
pairs: OPUS_MT.map((m) => m.pair),
modelFor: (i: number) => OPUS_MT[i]!.model,
},
{
label: 'EuroLLM',
description:
'One 1.7 GB model for 35 languages, so switching the pair does not reload it. Translates a line at a time.',
pairs: EUROLLM_PAIRS,
modelFor: () => models.translation.EUROLLM_1_7B.DEFAULT as TranslatorModel,
},
{
label: 'Hy-MT2',
description:
'One 1.2 GB model for 38 languages and dialects, strongest on Chinese. Translates a line at a time.',
pairs: HY_MT2_PAIRS,
modelFor: () => models.translation.HY_MT2_1_8B.DEFAULT as TranslatorModel,
},
];

function TranslationContent() {
const [family, setFamily] = useState(0);
const [selected, setSelected] = useState(0);
const { pairs, modelFor, description } = FAMILIES[family]!;
const active = pairs[selected]!;
const { isReady, downloadProgress, error, translate, translateStop } = useTranslator(
modelFor(selected)
);

const [text, setText] = useState(SAMPLES[active.languages.sourceLanguage] ?? '');
const [output, setOutput] = useState('');
const [busy, setBusy] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [elapsedMs, setElapsedMs] = useState<number | null>(null);

const select = (nextFamily: number, nextPair: number) => {
if (nextFamily === family && nextPair === selected) return;
translateStop?.();
setFamily(nextFamily);
setSelected(nextPair);
const source = FAMILIES[nextFamily]!.pairs[nextPair]!.languages.sourceLanguage;
setText(SAMPLES[source] ?? '');
setOutput('');
setRunError(null);
setElapsedMs(null);
};

const run = async () => {
if (!translate || !text.trim()) return;
setBusy(true);
setRunError(null);
setOutput('');
setElapsedMs(null);
try {
const startedAt = Date.now();
const result = await translate(text, active.languages, (piece) =>
setOutput((prev) => prev + piece)
);
setElapsedMs(Date.now() - startedAt);
setOutput(result);
} catch (e: any) {
const msg = e?.message ?? String(e);
if (!/disposed/i.test(msg)) setRunError(msg);
} finally {
setBusy(false);
}
};

const chips = (labels: string[], current: number, onPress: (i: number) => void) => (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipRow}
>
{labels.map((label, i) => (
<TouchableOpacity
key={label}
style={[styles.chip, i === current && styles.chipActive]}
onPress={() => onPress(i)}
>
<Text style={[styles.chipText, i === current && styles.chipTextActive]}>{label}</Text>
</TouchableOpacity>
))}
</ScrollView>
);

return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.card}>
<Text style={styles.cardTitle}>Translation</Text>
<Text style={styles.cardDescription}>{description}</Text>

<Text style={styles.fieldLabel}>Model</Text>
{chips(
FAMILIES.map((f) => f.label),
family,
(i) => select(i, 0)
)}

<Text style={styles.fieldLabel}>Language pair</Text>
{chips(
pairs.map((p) => p.label),
selected,
(i) => select(family, i)
)}

<ModelStatus
isReady={isReady}
downloadProgress={downloadProgress}
error={error ? error.message : null}
modelTypeLabel="model"
/>
</View>

{runError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{runError}</Text>
</View>
)}

<View style={styles.card}>
<Text style={styles.sectionTitle}>Text to translate</Text>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="Type or paste text…"
placeholderTextColor="#999"
multiline
/>
<View style={styles.buttonRow}>
<Button
title="Translate"
onPress={run}
disabled={!isReady || !translate || !text.trim()}
loading={busy}
/>
<Button
title="Stop"
variant="secondary"
onPress={() => translateStop?.()}
disabled={!busy}
/>
</View>
{elapsedMs !== null && <Text style={styles.statsText}>Time: {elapsedMs} ms</Text>}
</View>

{output !== '' && (
<View style={styles.card}>
<Text style={styles.sectionTitle}>Translation</Text>
<Text style={styles.outputText}>{output}</Text>
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
}

export default function TranslationScreen() {
return (
<ScreenWrapper>
<TranslationContent />
</ScreenWrapper>
);
}

const styles = StyleSheet.create({
flex: { flex: 1 },
container: { flex: 1, backgroundColor: theme.colors.background },
content: { padding: theme.spacing.large, paddingBottom: 40 },
card: {
backgroundColor: theme.colors.cardBackground,
borderRadius: theme.radius.large,
padding: 20,
marginBottom: 20,
borderWidth: 1,
borderColor: theme.colors.lightBorder,
},
cardTitle: {
fontSize: theme.typography.title.fontSize,
fontWeight: theme.typography.title.fontWeight,
color: theme.colors.strongPrimary,
marginBottom: 8,
},
cardDescription: {
fontSize: 14,
color: theme.colors.textMuted,
lineHeight: 20,
marginBottom: 16,
},
fieldLabel: {
fontSize: 12,
fontWeight: '600',
color: theme.colors.textPlaceholder,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
chipRow: { gap: 8, paddingBottom: 4, marginBottom: 12 },
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f1f3f5',
borderWidth: 1,
borderColor: theme.colors.lightBorder,
},
chipActive: {
backgroundColor: theme.colors.strongPrimary,
borderColor: theme.colors.strongPrimary,
},
chipText: { fontSize: 13, fontWeight: '600', color: theme.colors.textMuted },
chipTextActive: { color: '#fff' },
sectionTitle: { fontSize: 16, fontWeight: '700', color: '#212529', marginBottom: 12 },
input: {
backgroundColor: '#f1f3f5',
borderRadius: theme.radius.small,
padding: 12,
fontSize: 14,
color: '#212529',
marginBottom: 16,
minHeight: 140,
textAlignVertical: 'top',
borderWidth: 1,
borderColor: theme.colors.lightBorder,
},
buttonRow: { flexDirection: 'row', gap: theme.spacing.small },
statsText: {
fontSize: 13,
color: theme.colors.textPlaceholder,
marginTop: 12,
textAlign: 'center',
},
errorContainer: {
backgroundColor: theme.colors.errorBackground,
padding: 12,
borderRadius: theme.radius.small,
marginBottom: 20,
},
errorText: { color: theme.colors.errorText, fontSize: 14, textAlign: 'center' },
outputText: { fontSize: 15, lineHeight: 22, color: '#212529' },
});
Loading
Loading