Een Flutter-app vertalen met Ollama of LM Studio

Bijgewerkt op · LocalePilot

Een model op je eigen computer kan conceptvertalingen voor je ARB-bestanden maken, zonder abonnement en zonder je tekst naar een online dienst te sturen. Deze handleiding behandelt de installatie, een Dart-script om mee te beginnen en de plekken waar het model de mist in gaat.

Een lokaal model vervangt geen vertaler. Voor korte UI-teksten levert het wel gratis bruikbare concepten op, en je tekst verlaat je computer nooit. Het meeste werk zit rond het model: context geven, placeholders beschermen, nakijken wat terugkomt.

Een model installeren

Met Ollama downloadt één commando het model, waarna een lokale server luistert op http://localhost:11434:

ollama pull gemma3:12b

Een model van 12 miljard parameters past in de standaard gekwantiseerde versie in 16 GB geheugen. Op een kleinere computer kan gemma3:4b eenvoudige teksten aan, met meer fouten in lange zinnen.

Met LM Studio kies je het model in de app en start je de lokale server: die biedt een OpenAI-compatibele API, standaard op http://localhost:1234/v1.

Wat het model moet weten

Stuur je alleen "Share list", dan moet het model raden of het een knop, een titel of een werkwoord is. Drie dingen maken een duidelijk verschil:

  • De beschrijving van de sleutel, als je @key.description in je ARB-bestanden invult. Dat is de beste reden om het te doen.
  • De context van de app: "app voor gezinsboodschappenlijsten, eenvoudige toon, informeel 'je' in het Nederlands".
  • Opmaakregels: elke {placeholder} en de ICU-syntaxis intact laten, alleen met de vertaling antwoorden.

Zet ook de temperatuur op 0: bij vertalen wil je het meest waarschijnlijke antwoord, geen variatie.

Een script om mee te beginnen

Dit Dart-script vertaalt de sleutels die in app_fr.arb ontbreken (of leeg zijn), een voor een, met Ollama. Het raakt je ARB-bestanden niet aan: concepten gaan naar een apart bestand, en elk antwoord waarvan de placeholders veranderd zijn, wordt opzijgezet.

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

Voeg de context van je app toe aan de prompt, en je doeltaal als dat geen Frans is (voor Nederlands: app_nl.arb en "to Dutch").

Waar het misgaat

ICU-meervoud. Dit is het zwakke punt. Een model kan het trefwoord other vertalen, een tak laten vallen of {count} in één tak hernoemen. De placeholdervergelijking van het script vangt een deel daarvan; kijk voor de rest elk meervoud na. Let ook op takken =1, die in het Frans voor nul gelden (details in de handleiding over ICU-meervoud).

Eigennamen. De naam van je app, je betaalde abonnement, de naam van een functie: modellen vertalen ze graag. Zet ze in de prompt als termen om te behouden, en controleer ze achteraf.

Consistentie. Elke tekst wordt los vertaald, dus "item" kan hier "artikel" worden en daar "onderdeel". Een korte woordenlijst in de prompt lost het meeste op.

Lengte. Nederlands, Frans en Duits zijn vaak langer dan Engels. Een knop die in het Engels paste, kan overlopen, en dat zie je alleen op het scherm.

Met LM Studio

Alleen het verzoek verandert. De API volgt het OpenAI-formaat: stuur een lijst berichten naar /v1/chat/completions en lees choices[0].message.content uit het antwoord. De rest van het script blijft hetzelfde.

Nakijken, dan samenvoegen

Open het conceptbestand naast app_en.arb en loop het sleutel voor sleutel door. Kopieer wat goed is naar app_fr.arb, voer flutter gen-l10n opnieuw uit en bekijk de betreffende schermen. Die cyclus automatiseert LocalePilot: suggesties van Ollama of LM Studio komen in de werkruimte binnen als "AI-concept om na te kijken", de QA vergelijkt de placeholders en er wordt niets naar je bestanden geschreven vóór de export.

Veelgestelde vragen

Welk model gebruik ik voor app-teksten?

Een recent algemeen 12B-model zoals gemma3:12b geeft goede concepten voor korte teksten. Op een kleinere computer werkt een 4B-versie, met meer fouten.

Verlaat mijn tekst mijn computer?

Nee. Ollama en LM Studio draaien het model lokaal; verzoeken gaan naar localhost.

Kan ik direct in mijn ARB-bestanden schrijven?

Beter niet: schrijf naar een conceptbestand en kijk na voordat je samenvoegt. Een model kan een ICU-meervoud breken of een merknaam vertalen.