536 lines
17 KiB
Dart
536 lines
17 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:votianlt_app/config/translation_config.dart';
|
||
import 'package:votianlt_app/services/dart_mq.dart';
|
||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||
import 'package:votianlt_app/app_state.dart';
|
||
|
||
/// Service für Übersetzungen – unterstützt LM Studio (lokal) und Moonshot AI (Cloud).
|
||
///
|
||
/// Das aktive Backend wird in [TranslationConfig.activeBackend] konfiguriert.
|
||
/// Verwendet das Singleton-Pattern wie andere Services in der App.
|
||
/// Übersetzt in die vom Benutzer in der App eingestellte Sprache.
|
||
class TranslationService {
|
||
static final TranslationService _instance = TranslationService._internal();
|
||
factory TranslationService() => _instance;
|
||
TranslationService._internal();
|
||
|
||
static const String _chatCompletionsEndpoint = '/chat/completions';
|
||
|
||
// HTTP Client
|
||
final http.Client _client = http.Client();
|
||
|
||
// Verfügbarkeitsstatus
|
||
bool _isAvailable = false;
|
||
|
||
// Aktuell eingestellte Zielsprache (aus der App)
|
||
String get _targetLanguageCode => AppState().languageCode;
|
||
|
||
/// Gibt an ob das Übersetzungsbackend verfügbar ist
|
||
bool get isAvailable => _isAvailable;
|
||
|
||
/// Name des aktiven Backends (für Logs und DartMQ-Nachrichten)
|
||
String get _backendName => switch (TranslationConfig.activeBackend) {
|
||
TranslationBackend.lmStudio => 'lm-studio',
|
||
TranslationBackend.moonshot => 'moonshot-ai',
|
||
};
|
||
|
||
// Verfügbare Sprachen für Übersetzung (alle unterstützten App-Sprachen)
|
||
static final Map<String, String> supportedLanguages = {
|
||
'de': 'German',
|
||
'en': 'English',
|
||
'es': 'Spanish',
|
||
'fr': 'French',
|
||
'pl': 'Polish',
|
||
'ru': 'Russian',
|
||
'tr': 'Turkish',
|
||
'et': 'Estonian',
|
||
'lv': 'Latvian',
|
||
'lt': 'Lithuanian',
|
||
};
|
||
|
||
/// Initialisiert den Translation Service
|
||
Future<void> initialize() async {
|
||
try {
|
||
// Auf Sprachänderungen hören
|
||
_listenToLanguageChanges();
|
||
|
||
// Verfügbarkeit prüfen
|
||
_isAvailable = await _checkAvailability();
|
||
|
||
_notifyInitialization();
|
||
|
||
developer.log(
|
||
'TranslationService initialisiert - Backend: $_backendName, Zielsprache: ${supportedLanguages[_targetLanguageCode]}',
|
||
name: 'TranslationService',
|
||
);
|
||
} catch (e) {
|
||
developer.log('Fehler bei Initialisierung des TranslationService: $e',
|
||
name: 'TranslationService');
|
||
_isAvailable = false;
|
||
}
|
||
}
|
||
|
||
/// Prüft ob das konfigurierte Backend erreichbar ist
|
||
Future<bool> _checkAvailability() async {
|
||
switch (TranslationConfig.activeBackend) {
|
||
case TranslationBackend.lmStudio:
|
||
return _checkLmStudioAvailability();
|
||
case TranslationBackend.moonshot:
|
||
return _checkMoonshotAvailability();
|
||
}
|
||
}
|
||
|
||
Future<bool> _checkLmStudioAvailability() async {
|
||
try {
|
||
final response = await _client
|
||
.get(Uri.parse('${TranslationConfig.lmStudioBaseUrl}/v1/models'))
|
||
.timeout(const Duration(seconds: 5));
|
||
|
||
if (response.statusCode == 200) {
|
||
final data = jsonDecode(response.body);
|
||
final models = data['data'] as List<dynamic>?;
|
||
developer.log(
|
||
'LM Studio verbunden - Verfügbare Modelle: ${models?.length ?? 0}',
|
||
name: 'TranslationService',
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
developer.log('LM Studio nicht erreichbar: $e', name: 'TranslationService');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<bool> _checkMoonshotAvailability() async {
|
||
try {
|
||
final response = await _client
|
||
.get(
|
||
Uri.parse('${TranslationConfig.moonshotBaseUrl}/models'),
|
||
headers: {'Authorization': 'Bearer ${TranslationConfig.moonshotApiKey}'},
|
||
)
|
||
.timeout(const Duration(seconds: 5));
|
||
|
||
if (response.statusCode == 200) {
|
||
developer.log('Moonshot AI verbunden - API erreichbar', name: 'TranslationService');
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
developer.log('Moonshot AI nicht erreichbar: $e', name: 'TranslationService');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Sendet Initialisierungs-Notification über DartMQ
|
||
void _notifyInitialization() {
|
||
DartMQ().publish<Map<String, dynamic>>('translation/service_initialized', {
|
||
'language': _targetLanguageCode,
|
||
'backend': _backendName,
|
||
'isAvailable': isAvailable,
|
||
'endpoint': _activeEndpoint,
|
||
});
|
||
}
|
||
|
||
/// Hört auf Sprachänderungen und aktualisiert den Service
|
||
void _listenToLanguageChanges() {
|
||
localeNotifier.addListener(() {
|
||
final newLanguage = AppState().languageCode;
|
||
developer.log(
|
||
'Sprache in App geändert zu: ${supportedLanguages[newLanguage] ?? newLanguage}',
|
||
name: 'TranslationService',
|
||
);
|
||
DartMQ().publish<Map<String, dynamic>>('translation/language_changed', {
|
||
'language': newLanguage,
|
||
'displayName': supportedLanguages[newLanguage],
|
||
'backend': _backendName,
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Basis-URL des aktiven Backends
|
||
String get _activeEndpoint => switch (TranslationConfig.activeBackend) {
|
||
TranslationBackend.lmStudio => TranslationConfig.lmStudioBaseUrl,
|
||
TranslationBackend.moonshot => TranslationConfig.moonshotBaseUrl,
|
||
};
|
||
|
||
/// Übersetzt einen Text in die vom Benutzer eingestellte Sprache
|
||
///
|
||
/// [text] - Der zu übersetzende Text
|
||
/// [sourceLanguage] - Die Ausgangssprache (optional, wird automatisch erkannt wenn null)
|
||
///
|
||
/// Gibt den übersetzten Text zurück oder den Originaltext bei Fehlern
|
||
Future<String> translate(
|
||
String text, {
|
||
String? sourceLanguage,
|
||
}) async {
|
||
if (text.isEmpty) {
|
||
return text;
|
||
}
|
||
|
||
// Bei rein numerischem Text oder sehr kurzem Text nicht übersetzen
|
||
if (_shouldSkipTranslation(text)) {
|
||
developer.log('Übersetzung übersprungen (kein Text): "$text"',
|
||
name: 'TranslationService');
|
||
return text;
|
||
}
|
||
|
||
// Zielsprache aus der App holen
|
||
final targetCode = _targetLanguageCode;
|
||
|
||
// Wenn Quelle gleich Ziel, nicht übersetzen
|
||
final detectedSource = sourceLanguage ?? await _detectLanguage(text);
|
||
if (detectedSource == targetCode) {
|
||
developer.log('Übersetzung übersprungen (Quelle = Ziel): "$text"',
|
||
name: 'TranslationService');
|
||
return text;
|
||
}
|
||
|
||
try {
|
||
final translatedText = await _translate(text, detectedSource, targetCode);
|
||
|
||
developer.log(
|
||
'Übersetzung [${supportedLanguages[detectedSource]} -> ${supportedLanguages[targetCode]}]:\n'
|
||
' Original: "$text"\n'
|
||
' Übersetzt: "$translatedText"',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
return translatedText;
|
||
} catch (e) {
|
||
developer.log(
|
||
'Fehler bei der Übersetzung: $e\n Original: "$text"',
|
||
name: 'TranslationService',
|
||
);
|
||
return text; // Bei Fehler Original zurückgeben
|
||
}
|
||
}
|
||
|
||
/// Übersetzt eine Liste von Texten in die vom Benutzer eingestellte Sprache
|
||
Future<List<String>> translateList(
|
||
List<String> texts, {
|
||
String? sourceLanguage,
|
||
}) async {
|
||
if (texts.isEmpty) return texts;
|
||
|
||
final results = <String>[];
|
||
final targetCode = _targetLanguageCode;
|
||
|
||
developer.log(
|
||
'Starte Batch-Übersetzung von ${texts.length} Texten nach ${supportedLanguages[targetCode]}',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
for (int i = 0; i < texts.length; i++) {
|
||
final text = texts[i];
|
||
|
||
if (text.isEmpty || _shouldSkipTranslation(text)) {
|
||
results.add(text);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
final detectedSource = sourceLanguage ?? await _detectLanguage(text);
|
||
|
||
if (detectedSource == targetCode) {
|
||
results.add(text);
|
||
continue;
|
||
}
|
||
|
||
final translatedText = await _translate(text, detectedSource, targetCode);
|
||
|
||
developer.log(
|
||
'Batch [${i + 1}/${texts.length}] [${supportedLanguages[detectedSource]} -> ${supportedLanguages[targetCode]}]:\n'
|
||
' Original: "$text"\n'
|
||
' Übersetzt: "$translatedText"',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
results.add(translatedText);
|
||
} catch (e) {
|
||
developer.log(
|
||
'Fehler bei Batch-Übersetzung [${i + 1}/${texts.length}]: $e\n Original: "$text"',
|
||
name: 'TranslationService',
|
||
);
|
||
results.add(text);
|
||
}
|
||
}
|
||
|
||
developer.log(
|
||
'Batch-Übersetzung abgeschlossen: ${texts.length} Texte verarbeitet',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
return results;
|
||
}
|
||
|
||
/// Dispatcht die Übersetzung an das konfigurierte Backend
|
||
Future<String> _translate(String text, String sourceCode, String targetCode) {
|
||
switch (TranslationConfig.activeBackend) {
|
||
case TranslationBackend.lmStudio:
|
||
return _translateWithLmStudio(text, sourceCode, targetCode);
|
||
case TranslationBackend.moonshot:
|
||
return _translateWithMoonshot(text, sourceCode, targetCode);
|
||
}
|
||
}
|
||
|
||
/// Übersetzung mit LM Studio REST API (lokales Modell, kein API-Key)
|
||
Future<String> _translateWithLmStudio(
|
||
String text,
|
||
String sourceCode,
|
||
String targetCode,
|
||
) async {
|
||
final targetName = supportedLanguages[targetCode] ?? targetCode;
|
||
final sourceName = supportedLanguages[sourceCode] ?? sourceCode;
|
||
|
||
final systemPrompt =
|
||
'You are a professional translator. Translate the user input from $sourceName to $targetName. '
|
||
'Return ONLY the translation, without any additional text, explanations, or quotes.';
|
||
|
||
final requestBody = {
|
||
'model': TranslationConfig.lmStudioModel,
|
||
'messages': [
|
||
{'role': 'system', 'content': systemPrompt},
|
||
{'role': 'user', 'content': text},
|
||
],
|
||
'temperature': 0.1,
|
||
'max_tokens': 2048,
|
||
'stream': false,
|
||
};
|
||
|
||
developer.log(
|
||
'Sende Übersetzungsanfrage an LM Studio: $sourceName -> $targetName (${text.length} Zeichen)',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse('${TranslationConfig.lmStudioBaseUrl}$_chatCompletionsEndpoint'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode(requestBody),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
|
||
if (response.statusCode != 200) {
|
||
throw Exception('LM Studio API Fehler: ${response.statusCode} - ${response.body}');
|
||
}
|
||
|
||
return _extractTranslation(response.body, 'LM Studio');
|
||
}
|
||
|
||
/// Übersetzung mit Moonshot AI Cloud API (Kimi, API-Key erforderlich)
|
||
Future<String> _translateWithMoonshot(
|
||
String text,
|
||
String sourceCode,
|
||
String targetCode,
|
||
) async {
|
||
final targetName = supportedLanguages[targetCode] ?? targetCode;
|
||
final sourceName = supportedLanguages[sourceCode] ?? sourceCode;
|
||
|
||
final systemPrompt =
|
||
'You are a professional translator. Translate the user input from $sourceName to $targetName. '
|
||
'Return ONLY the translation, without any additional text, explanations, or quotes.';
|
||
|
||
final requestBody = {
|
||
'model': TranslationConfig.moonshotModel,
|
||
'messages': [
|
||
{'role': 'system', 'content': systemPrompt},
|
||
{'role': 'user', 'content': text},
|
||
],
|
||
'temperature': 0.1,
|
||
'max_tokens': 2048,
|
||
'stream': false,
|
||
};
|
||
|
||
developer.log(
|
||
'Sende Übersetzungsanfrage an Moonshot AI: $sourceName -> $targetName (${text.length} Zeichen)',
|
||
name: 'TranslationService',
|
||
);
|
||
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse('${TranslationConfig.moonshotBaseUrl}$_chatCompletionsEndpoint'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ${TranslationConfig.moonshotApiKey}',
|
||
},
|
||
body: jsonEncode(requestBody),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
|
||
if (response.statusCode != 200) {
|
||
throw Exception('Moonshot AI API Fehler: ${response.statusCode} - ${response.body}');
|
||
}
|
||
|
||
return _extractTranslation(response.body, 'Moonshot AI');
|
||
}
|
||
|
||
/// Extrahiert den Übersetzungstext aus der OpenAI-kompatiblen API-Antwort
|
||
String _extractTranslation(String responseBody, String backendLabel) {
|
||
final data = jsonDecode(responseBody);
|
||
final choices = data['choices'] as List<dynamic>?;
|
||
|
||
if (choices == null || choices.isEmpty) {
|
||
throw Exception('Leere Antwort von $backendLabel');
|
||
}
|
||
|
||
final message = choices[0]['message'] as Map<String, dynamic>?;
|
||
String translated = message?['content']?.toString().trim() ?? '';
|
||
|
||
// Anführungszeichen entfernen falls vorhanden
|
||
if ((translated.startsWith('"') && translated.endsWith('"')) ||
|
||
(translated.startsWith("'") && translated.endsWith("'"))) {
|
||
translated = translated.substring(1, translated.length - 1);
|
||
}
|
||
|
||
return translated;
|
||
}
|
||
|
||
/// Hilfsmethode: Erkennt die Sprache eines Textes
|
||
Future<String> _detectLanguage(String text) async {
|
||
// Für kurze Texte: Default zu Englisch
|
||
if (text.length < 10) {
|
||
return 'en';
|
||
}
|
||
|
||
// Einfache Heuristik basierend auf häufigen Wörtern/Zeichen
|
||
final lowerText = text.toLowerCase();
|
||
|
||
// Deutsche Wörter prüfen
|
||
final germanWords = ['der', 'die', 'das', 'und', 'ist', 'zu', 'den', 'mit', 'von', 'für'];
|
||
if (germanWords.any((word) =>
|
||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||
return 'de';
|
||
}
|
||
|
||
// Französische Wörter prüfen
|
||
final frenchWords = ['le', 'la', 'les', 'et', 'est', 'pour', 'dans', 'sur', 'avec', 'une'];
|
||
if (frenchWords.any((word) =>
|
||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||
return 'fr';
|
||
}
|
||
|
||
// Spanische Wörter prüfen
|
||
final spanishWords = ['el', 'la', 'los', 'las', 'y', 'es', 'para', 'con', 'por', 'del'];
|
||
if (spanishWords.any((word) =>
|
||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||
return 'es';
|
||
}
|
||
|
||
// Polnische Wörter prüfen
|
||
final polishWords = ['jest', 'i', 'w', 'na', 'do', 'nie', 'się', 'tego', 'tej'];
|
||
if (polishWords.any((word) =>
|
||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||
return 'pl';
|
||
}
|
||
|
||
// Russische/Cyrillische Zeichen prüfen
|
||
if (RegExp(r'[а-яА-Я]').hasMatch(text)) {
|
||
return 'ru';
|
||
}
|
||
|
||
// Türkische Zeichen prüfen
|
||
if (RegExp(r'[çğıöşüÇĞİÖŞÜ]').hasMatch(text)) {
|
||
return 'tr';
|
||
}
|
||
|
||
// Estnische Zeichen prüfen
|
||
if (RegExp(r'[äöüõÄÖÜÕ]').hasMatch(text)) {
|
||
return 'et';
|
||
}
|
||
|
||
// Lettische Zeichen prüfen
|
||
if (RegExp(r'[āčēģīķļņšūžĀČĒĢĪĶĻŅŠŪŽ]').hasMatch(text)) {
|
||
return 'lv';
|
||
}
|
||
|
||
// Litauische Zeichen prüfen
|
||
if (RegExp(r'[ąčęėįšųūžĄČĘĖĮŠŲŪŽ]').hasMatch(text)) {
|
||
return 'lt';
|
||
}
|
||
|
||
// Arabische Zeichen prüfen
|
||
if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) {
|
||
return 'ar';
|
||
}
|
||
|
||
// Chinesische/Japanische/Koreanische Zeichen prüfen
|
||
if (RegExp(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]').hasMatch(text)) {
|
||
return 'zh';
|
||
}
|
||
|
||
// Default: Englisch
|
||
return 'en';
|
||
}
|
||
|
||
/// Prüft ob die Übersetzung übersprungen werden sollte
|
||
bool _shouldSkipTranslation(String text) {
|
||
// Numerische Werte nicht übersetzen
|
||
if (RegExp(r'^\d+$').hasMatch(text.trim())) {
|
||
return true;
|
||
}
|
||
|
||
// Sehr kurze Codes nicht übersetzen
|
||
if (text.trim().length <= 2) {
|
||
return true;
|
||
}
|
||
|
||
// E-Mail Adressen nicht übersetzen
|
||
if (text.contains('@') && text.contains('.')) {
|
||
return true;
|
||
}
|
||
|
||
// URLs nicht übersetzen
|
||
if (text.startsWith('http://') || text.startsWith('https://')) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// Prüft ob ein Übersetzungsmodell verfügbar ist
|
||
Future<bool> isModelAvailable() async {
|
||
return _checkAvailability();
|
||
}
|
||
|
||
/// Gibt detaillierte Verfügbarkeitsinformationen zurück
|
||
Future<Map<String, dynamic>> getAvailabilityInfo() async {
|
||
final isOnline = await _checkAvailability();
|
||
return {
|
||
'isAvailable': isOnline,
|
||
'backend': _backendName,
|
||
'targetLanguage': _targetLanguageCode,
|
||
'endpoint': _activeEndpoint,
|
||
'platform': Platform.operatingSystem,
|
||
};
|
||
}
|
||
|
||
/// Gibt die aktuell eingestellte Zielsprache zurück
|
||
String get targetLanguageCode => _targetLanguageCode;
|
||
|
||
/// Gibt den Anzeigenamen der aktuellen Sprache zurück
|
||
String get targetLanguageDisplayName {
|
||
return supportedLanguages[_targetLanguageCode] ?? _targetLanguageCode;
|
||
}
|
||
|
||
/// Gibt eine Liste aller verfügbaren Sprachen zurück
|
||
List<MapEntry<String, String>> getAvailableLanguages() {
|
||
return supportedLanguages.entries.toList();
|
||
}
|
||
|
||
/// Gibt den Anzeigenamen einer Sprache zurück
|
||
String getLanguageDisplayName(String code) {
|
||
return supportedLanguages[code] ?? code;
|
||
}
|
||
|
||
/// Schließt den Service und gibt Ressourcen frei
|
||
Future<void> dispose() async {
|
||
_client.close();
|
||
_isAvailable = false;
|
||
|
||
developer.log('TranslationService disposed', name: 'TranslationService');
|
||
}
|
||
}
|