import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Yerel Ollama LLM istemcisi (senaryo → sahne çözümleme). final ollamaServisiProvider = Provider((ref) => OllamaServisi()); /// Ollama'da kurulu modeller (canlı). Boşsa motor kapalı/ulaşılamıyor. final ollamaModelleriProvider = FutureProvider>((ref) => ref.watch(ollamaServisiProvider).modeller()); /// AI'nin önerdiği tek sahne (henüz Isar'a yazılmamış ham çıktı). class AiSahneOnerisi { const AiSahneOnerisi({ required this.mekan, required this.zaman, required this.aksiyon, required this.diyalog, required this.prompt, required this.karakterAdlari, }); final String mekan; final String zaman; final String aksiyon; final String diyalog; final String prompt; final List karakterAdlari; factory AiSahneOnerisi.fromJson(Map j) => AiSahneOnerisi( mekan: (j['mekan'] ?? '').toString().trim(), zaman: (j['zaman'] ?? '').toString().trim(), aksiyon: (j['aksiyon'] ?? '').toString().trim(), diyalog: (j['diyalog'] ?? '').toString().trim(), prompt: (j['prompt'] ?? '').toString().trim(), karakterAdlari: [ for (final k in (j['karakterler'] as List?) ?? []) k.toString().trim(), ].where((s) => s.isNotEmpty).toList(), ); } class OllamaServisi { OllamaServisi() : _dio = Dio(BaseOptions( baseUrl: 'http://127.0.0.1:11434', connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(minutes: 4), )); final Dio _dio; /// GET /api/tags → kurulu model adları. Future> modeller() async { try { final cevap = await _dio.get('/api/tags'); final liste = (cevap.data as Map)['models'] as List? ?? []; return [for (final m in liste) (m as Map)['name'].toString()]; } catch (_) { return const []; } } /// Tercih sırasına göre mevcut bir model seçer (yoksa ilk kurulu model). Future uygunModel() async { final kurulu = await modeller(); if (kurulu.isEmpty) return null; const tercih = ['gemma4:12b', 'gemma2', 'llama3.1:8b', 'llama3.1', 'llama3']; for (final t in tercih) { final b = kurulu.firstWhere((m) => m.startsWith(t), orElse: () => ''); if (b.isNotEmpty) return b; } return kurulu.first; } /// Sinopsisi N sahneye böler. [karakterler] mevcut karakter adları (eşleme ipucu). /// Hata/timeout durumunda [OllamaHatasi] (Türkçe) fırlatır. Future> senaryoCozumle({ required String konu, required int sahneSayisi, String tur = '', String stil = '', List karakterler = const [], }) async { final model = await uygunModel(); if (model == null) { throw OllamaHatasi( 'Ollama\'da kurulu model bulunamadı. "ollama pull llama3.1" ile bir model indirin.'); } final karakterIpucu = karakterler.isEmpty ? '' : 'Mevcut karakter isimleri (uygun olanları AYNEN kullan): ' '${karakterler.join(", ")}.\n'; final stilIpucu = [ if (tur.isNotEmpty) 'tür: $tur', if (stil.isNotEmpty) 'görsel stil: $stil', ].join(', '); final istek = ''' Sen profesyonel bir senaryo yazarısın. Aşağıdaki konuyu tam olarak $sahneSayisi sahneye böl. ${stilIpucu.isEmpty ? '' : 'Proje $stilIpucu.\n'}$karakterIpucu Konu: $konu Her sahne için şu alanları doldur: - mekan: çekim mekanı (Türkçe, kısa) - zaman: gündüz/gece/şafak/gün batımı/öğlen/iç mekan - aksiyon: sahnede ne olduğu (Türkçe, 1-2 cümle) - diyalog: karakter konuşmaları (Türkçe, yoksa boş) - prompt: sinematik görsel betimleme (İNGİLİZCE, kamera açısı/ışık/atmosfer dahil) - karakterler: bu sahnedeki karakter isimleri (dizi) SADECE şu formatta geçerli JSON döndür, başka hiçbir metin ekleme: {"sahneler":[{"mekan":"","zaman":"","aksiyon":"","diyalog":"","prompt":"","karakterler":[]}]} '''; try { final cevap = await _dio.post('/api/generate', data: { 'model': model, 'prompt': istek, 'stream': false, 'format': 'json', 'options': {'temperature': 0.8}, }); final ham = (cevap.data as Map)['response']?.toString() ?? ''; return _ayikla(ham); } on DioException catch (e) { throw OllamaHatasi(_dioHata(e)); } catch (e) { throw OllamaHatasi('Senaryo çözümlenemedi: ${e.toString()}'); } } List _ayikla(String ham) { final json = _ilkJsonBloku(ham); if (json == null) { throw OllamaHatasi('AI geçerli bir sahne listesi döndürmedi. Tekrar deneyin.'); } try { final cozulen = jsonDecode(json); final liste = cozulen is Map ? (cozulen['sahneler'] as List? ?? const []) : (cozulen as List? ?? const []); final sahneler = [ for (final s in liste) if (s is Map) AiSahneOnerisi.fromJson(Map.from(s)), ]; if (sahneler.isEmpty) { throw OllamaHatasi('AI boş sahne listesi döndürdü. Tekrar deneyin.'); } return sahneler; } on OllamaHatasi { rethrow; } catch (_) { throw OllamaHatasi('AI yanıtı işlenemedi (geçersiz JSON). Tekrar deneyin.'); } } /// Ham metinden ilk dengeli JSON nesnesini/dizisini çıkarır. static String? _ilkJsonBloku(String m) { final bas = m.indexOf(RegExp(r'[{\[]')); if (bas < 0) return null; final acik = m[bas]; final kapali = acik == '{' ? '}' : ']'; var derinlik = 0; for (var i = bas; i < m.length; i++) { if (m[i] == acik) derinlik++; if (m[i] == kapali) { derinlik--; if (derinlik == 0) return m.substring(bas, i + 1); } } return null; } static String _dioHata(DioException e) { if (e.type == DioExceptionType.connectionError || e.type == DioExceptionType.connectionTimeout) { return 'Ollama\'ya ulaşılamıyor (127.0.0.1:11434). Ollama servisinin ' 'açık olduğundan emin olun.'; } if (e.type == DioExceptionType.receiveTimeout) { return 'AI yanıtı zaman aşımına uğradı. Daha az sahne deneyin veya ' 'daha hızlı bir model kullanın.'; } return 'Ollama hatası: ${e.message ?? e.toString()}'; } } class OllamaHatasi implements Exception { OllamaHatasi(this.mesaj); final String mesaj; @override String toString() => mesaj; }