ترجمة تطبيق Flutter باستخدام Ollama أو LM Studio

آخر تحديث: · LocalePilot

يستطيع نموذج يعمل على جهازك أن يقترح مسودات ترجمة لملفات ARB، دون اشتراك ودون إرسال نصوصك إلى خدمة على الإنترنت. إليك الإعداد، وسكربت Dart للبدء، والمواضع التي يخطئ فيها النموذج.

لن يحل نموذج محلي محل مترجم. لكنه يعطيك مجانًا مسودات مقبولة لنصوص الواجهة القصيرة، ولا تغادر نصوصك جهازك. معظم العمل يدور حول النموذج: إعطاؤه السياق، وحماية المتغيرات (placeholders)، ومراجعة ما يعيده.

تثبيت نموذج

مع Ollama، يكفي أمر واحد لتنزيل النموذج، ثم يستمع خادم محلي على http://localhost:11434:

ollama pull gemma3:12b

نموذج بـ12 مليار معامل، في نسخته المضغوطة (quantized) الافتراضية، يتسع في 16 GB من الذاكرة. وعلى جهاز أصغر، يتعامل gemma3:4b مع النصوص البسيطة، مع أخطاء أكثر في الجمل الطويلة.

مع LM Studio، تختار النموذج داخل التطبيق وتشغّل الخادم المحلي: يوفر واجهة API متوافقة مع OpenAI، على http://localhost:1234/v1 افتراضيًا.

ما يحتاج النموذج إلى معرفته

إرسال «Share list» وحده يترك النموذج يخمّن: هل هو زر أم عنوان أم فعل؟ ثلاثة أشياء تُحدث فرقًا واضحًا:

  • وصف المفتاح، إذا كنت تملأ @key.description في ملفات ARB. وهذا أفضل سبب لفعل ذلك.
  • سياق التطبيق: «تطبيق قائمة تسوق عائلية، بنبرة بسيطة، بصيغة المخاطَب المفرد».
  • قواعد التنسيق: الحفاظ على كل {placeholder} وعلى صياغة ICU كما هي، والرد بالترجمة فقط.

واضبط درجة الحرارة (temperature) على 0 أيضًا: في الترجمة تريد الإجابة الأرجح، لا التنوع.

سكربت للبدء

يترجم سكربت Dart هذا المفاتيح الناقصة (أو الفارغة) في app_fr.arb، واحدًا تلو الآخر، باستخدام Ollama. لا يلمس ملفات ARB: تذهب المسودات إلى ملف منفصل، وتُستبعد كل إجابة تغيّرت متغيراتها.

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

أضف سياق تطبيقك إلى الـprompt، واللغة الهدف إن لم تكن الفرنسية (للعربية مثلًا: from English to Arabic وapp_ar.arb).

أين تقع الأخطاء

صيغ الجمع في ICU. هذه نقطة الضعف. قد يترجم النموذج الكلمة المفتاحية other، أو يحذف فرعًا، أو يعيد تسمية {count} في فرع واحد. تلتقط مقارنة المتغيرات في السكربت جزءًا من ذلك؛ وراجع كل صيغة جمع لاكتشاف الباقي. وفي العربية، تأكد من وجود الفئات الست (zero وone وtwo وfew وmany وother)، فكثيرًا ما يكتفي النموذج بـone وother. وانتبه أيضًا لفروع =1 التي تنطبق على الصفر في الفرنسية (التفاصيل في دليل صيغ الجمع في ICU).

أسماء العلم. اسم تطبيقك، واسم خطتك المدفوعة، واسم ميزة: تميل النماذج إلى ترجمتها. اذكرها في الـprompt على أنها مصطلحات يجب إبقاؤها، وتحقق منها بعد ذلك.

الاتساق. كل نص يُترجم بمعزل عن غيره، فقد تصبح «item» «عنصرًا» هنا و«بندًا» هناك. مسرد مصطلحات قصير في الـprompt يحل معظم ذلك.

الطول. كثيرًا ما تكون الفرنسية والألمانية أطول من الإنجليزية. زر كان يتسع بالإنجليزية قد يفيض؛ والشاشة وحدها ستخبرك.

مع LM Studio

الطلب وحده يتغير. تتبع الواجهة صيغة OpenAI: أرسل قائمة رسائل إلى /v1/chat/completions واقرأ choices[0].message.content من الاستجابة. وبقية السكربت كما هي.

راجع، ثم ادمج

افتح ملف المسودات بجانب app_en.arb وراجعه مفتاحًا مفتاحًا. انسخ الجيد منه إلى app_fr.arb، وشغّل flutter gen-l10n مجددًا، وانظر إلى الشاشات المعنية. هذه الحلقة هي ما يؤتمته LocalePilot: تصل اقتراحات Ollama أو LM Studio إلى مساحة العمل بوصفها «مسودة ذكاء اصطناعي للمراجعة»، ويقارن فحص الجودة المتغيرات، ولا يُكتب شيء في ملفاتك قبل التصدير.

الأسئلة الشائعة

أي نموذج أستخدم لنصوص التطبيقات؟

نموذج عام حديث بحجم 12B مثل gemma3:12b يعطي مسودات جيدة للنصوص القصيرة. وعلى جهاز أصغر، تعمل نسخة 4B، مع أخطاء أكثر.

هل تغادر نصوصي جهازي؟

لا. يشغّل Ollama وLM Studio النموذج محليًا؛ والطلبات تذهب إلى localhost.

هل يمكنني الكتابة مباشرة في ملفات ARB؟

الأفضل الكتابة في ملف مسودات ومراجعته قبل الدمج: قد يكسر النموذج صيغة جمع ICU أو يترجم اسم علامة تجارية.