Tłumacz aplikację Flutter z Ollama lub LM Studio

Zaktualizowano · LocalePilot

Model działający na Twoim komputerze może przygotować szkice tłumaczeń plików ARB, bez subskrypcji i bez wysyłania tekstu do usługi online. Konfiguracja, startowy skrypt w Dart i miejsca, w których model się myli.

Lokalny model nie zastąpi tłumacza. Przy krótkich tekstach interfejsu daje jednak przyzwoite szkice za darmo, a tekst nigdy nie opuszcza komputera. Większość pracy jest wokół modelu: trzeba podać mu kontekst, chronić placeholdery i przejrzeć to, co zwraca.

Instalacja modelu

Z Ollama jedno polecenie pobiera model, a potem lokalny serwer nasłuchuje na http://localhost:11434:

ollama pull gemma3:12b

Model z 12 miliardami parametrów w domyślnej skwantyzowanej wersji mieści się w 16 GB pamięci. Na słabszym komputerze gemma3:4b poradzi sobie z prostymi tekstami, z większą liczbą błędów w długich zdaniach.

Z LM Studio wybierasz model w aplikacji i uruchamiasz lokalny serwer: udostępnia API zgodne z OpenAI, domyślnie pod http://localhost:1234/v1.

Co model musi wiedzieć

Wysłanie samego „Share list” zostawia model w niepewności, czy to przycisk, tytuł czy czasownik. Wyraźnie pomagają trzy rzeczy:

  • Opis klucza, jeśli wypełniasz @key.description w plikach ARB. To najlepszy powód, żeby to robić.
  • Kontekst aplikacji: „aplikacja z rodzinną listą zakupów, prosty ton, po polsku zwracamy się na ty”.
  • Reguły formatu: zachowaj każdy {placeholder} i składnię ICU bez zmian, odpowiedz samym tłumaczeniem.

Ustaw też temperaturę na 0: przy tłumaczeniu chcesz najbardziej prawdopodobnej odpowiedzi, nie różnorodności.

Skrypt na start

Ten skrypt w Dart tłumaczy klucze brakujące (lub puste) w app_fr.arb, jeden po drugim, przez Ollama. Nie dotyka plików ARB: szkice trafiają do osobnego pliku, a każda odpowiedź, w której zmieniły się placeholdery, jest odkładana na bok.

import 'dart:convert';
import 'dart:io';

const model = 'gemma3:12b';
final placeholder = RegExp(r'\{[A-Za-z_]\w*\}');

Future<void> main() async {
  final source = _read('lib/l10n/app_en.arb');
  final target = _read('lib/l10n/app_fr.arb');
  final drafts = <String, String>{};

  for (final key in source.keys.where((k) => !k.startsWith('@'))) {
    if ((target[key] as String?)?.isNotEmpty ?? false) continue;
    final text = source[key] as String;
    final description = (source['@$key'] as Map?)?['description'] ?? '';
    final draft = await _translate(text, description as String);
    if (_placeholders(draft) != _placeholders(text)) {
      stderr.writeln('$key: placeholders changed, redo by hand');
      continue;
    }
    drafts[key] = draft;
  }

  File('app_fr.drafts.json').writeAsStringSync(const JsonEncoder.withIndent('  ').convert(drafts));
  print('${drafts.length} drafts in app_fr.drafts.json');
}

Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;

String _placeholders(String text) => (placeholder.allMatches(text).map((m) => m[0]!).toList()..sort()).join(',');

Future<String> _translate(String text, String description) async {
  final client = HttpClient();
  final request = await client.postUrl(Uri.parse('http://localhost:11434/api/generate'));
  request.headers.contentType = ContentType.json;
  request.write(jsonEncode({
    'model': model,
    'stream': false,
    'options': {'temperature': 0},
    'prompt': 'Translate this mobile app string from English to French. '
        'Keep every {placeholder} and the ICU plural/select syntax unchanged. '
        'Context: $description\n'
        'Reply with the translation only.\n\n$text',
  }));
  final response = await request.close();
  final body = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
  client.close();
  return (body['response'] as String).trim();
}

Dodaj do promptu kontekst swojej aplikacji i język docelowy, jeśli to nie francuski (dla polskiego: app_pl.arb i „to Polish”).

Gdzie to się psuje

Liczba mnoga ICU. To słaby punkt. Model może przetłumaczyć słowo kluczowe other, zgubić gałąź albo przemianować {count} w jednej gałęzi. Po polsku często pomija też few lub many. Porównanie placeholderów w skrypcie wyłapuje część błędów; resztę wyłapiesz, przeglądając każdą liczbę mnogą. Uważaj też na gałęzie =1, które po francusku dotyczą zera (szczegóły w poradniku o liczbie mnogiej ICU).

Nazwy własne. Nazwa aplikacji, płatny plan, nazwa funkcji: modele lubią je tłumaczyć. Wypisz je w prompcie jako terminy do zachowania i sprawdź potem.

Spójność. Każdy tekst jest tłumaczony osobno, więc „item” może raz być „produktem”, a raz „elementem”. Krótki słownik w prompcie rozwiązuje większość problemów.

Długość. Polski, francuski i niemiecki często są dłuższe niż angielski. Przycisk, który mieścił się po angielsku, może się nie zmieścić, a sprawdzisz to tylko na ekranie.

Z LM Studio

Zmienia się tylko zapytanie. API stosuje format OpenAI: wyślij listę wiadomości do /v1/chat/completions i odczytaj choices[0].message.content z odpowiedzi. Reszta skryptu pozostaje bez zmian.

Przejrzyj, potem scal

Otwórz plik ze szkicami obok app_en.arb i przejdź przez niego klucz po kluczu. Skopiuj to, co dobre, do app_fr.arb, uruchom ponownie flutter gen-l10n i obejrzyj odpowiednie ekrany. Tę pętlę automatyzuje LocalePilot: propozycje z Ollama lub LM Studio trafiają do obszaru roboczego jako „Szkic AI do przeglądu”, QA porównuje placeholdery, a nic nie jest zapisywane w plikach przed eksportem.

Najczęściej zadawane pytania

Jakiego modelu użyć do tekstów aplikacji?

Nowy uniwersalny model 12B, taki jak gemma3:12b, daje dobre szkice krótkich tekstów. Na słabszym komputerze sprawdzi się wersja 4B, z większą liczbą błędów.

Czy mój tekst opuszcza komputer?

Nie. Ollama i LM Studio uruchamiają model lokalnie; zapytania trafiają na localhost.

Czy mogę zapisywać prosto do plików ARB?

Lepiej zapisywać do pliku ze szkicami i przejrzeć je przed scaleniem: model może zepsuć liczbę mnogą ICU albo przetłumaczyć nazwę marki.