1using System; 2using System.Collections.Generic; 3using System.Drawing; 4using System.Drawing.Imaging; 5using System.IO; 6using System.Net; 7using System.Text; 8using System.Threading; 9using System.ComponentModel; 10using WindowCapture.Helpers; 11using WindowCapture.Models; 12 13namespace WindowCapture.Integration 14{ 15 /// <summary> 16 /// Integration with Google Gemini API for image description generation 17 /// </summary> 18 public static class GeminiIntegration 19 { 20 private const string API_BASE = "https://generativelanguage.googleapis.com/v1beta/models/"; 21 22 /// <summary> 23 /// List of fallback models to try when rate limited (429) 24 /// Sorted by preference: fast/lite first, then standard, then pro/experimental 25 /// </summary> 26 public static readonly string[] FallbackModels = new string[] 27 { 28 // Newest generation (GA, May 2026) — fast/lite first since they have separate quotas. 29 "gemini-3.5-flash", // newest GA flash, recommended multimodal default 30 "gemini-3.1-flash-lite", // GA lite, separate quota 31 "gemini-3-flash-preview", 32 // Auto-updating aliases (track the newest stable flash) 33 "gemini-flash-latest", 34 "gemini-flash-lite-latest", 35 // Proven 2.5/2.0 flash tier (broad availability, good fallbacks) 36 "gemini-2.5-flash", 37 "gemini-2.5-flash-lite", 38 "gemini-2.0-flash", 39 "gemini-2.0-flash-lite", 40 "gemini-2.0-flash-001", 41 "gemini-2.0-flash-lite-001", 42 // Pro / reasoning tier (higher quality, tighter quotas) 43 "gemini-3.1-pro-preview", 44 "gemini-2.5-pro", 45 "gemini-pro-latest", 46 // Older preview / experimental (last-resort) 47 "gemini-3-pro-preview", 48 "gemini-2.5-flash-preview-09-2025", 49 "gemini-2.5-flash-lite-preview-09-2025", 50 "gemini-exp-1206" 51 }; 52 53 /// <summary> 54 /// Last successfully used model 55 /// </summary> 56 public static string LastUsedModel { get; private set; } 57 58 /// <summary> 59 /// Get settings from model 60 /// </summary> 61 private static GeminiSettings Settings 62 { 63 get { return TitlePageData.Instance.Gemini; } 64 } 65 66 /// <summary> 67 /// Get API URL for a specific model 68 /// </summary> 69 private static string GetApiUrl(string model) 70 { 71 return API_BASE + model + ":generateContent"; 72 } 73 74 /// <summary> 75 /// Get current API key from settings (supports multiple keys) 76 /// </summary> 77 public static string ApiKey 78 { 79 get 80 { 81 var settings = Settings; 82 // Try to get from key list first 83 string key = settings.GetCurrentApiKey(TitlePageData.Instance.GeminiApiKey); 84 if (!string.IsNullOrEmpty(key)) 85 return key; 86 87 // Fallback to environment variable 88 return Environment.GetEnvironmentVariable("GEMINI_API_KEY") ?? ""; 89 } 90 set 91 { 92 // Add to list if not present 93 if (Settings.AddApiKey(value)) 94 { 95 // Also keep legacy field for backward compatibility 96 if (string.IsNullOrEmpty(TitlePageData.Instance.GeminiApiKey)) 97 TitlePageData.Instance.GeminiApiKey = value; 98 TitlePageData.Instance.Save(); 99 } 100 } 101 } 102 103 /// <summary> 104 /// Check if API key is configured 105 /// </summary> 106 public static bool IsConfigured 107 { 108 get { return !string.IsNullOrEmpty(ApiKey); } 109 } 110 111 /// <summary> 112 /// Generate description with automatic model fallback on 429 errors 113 /// Returns: Tuple(description, caption, usedModel) or null on failure 114 /// </summary> 115 public static void GenerateDescriptionWithFallbackAsync( 116 Bitmap image, 117 Action<Tuple<string, string, string>> onComplete, 118 Action<string, Exception> onError, 119 Action<string> onModelSwitch, 120 string previousContext = null) 121 { 122 ThreadPool.QueueUserWorkItem(state => 123 { 124 try 125 { 126 var result = GenerateDescriptionWithFallback(image, previousContext, onModelSwitch); 127 128 if (result == null) 129 { 130 InvokeOnUI(() => 131 { 132 if (onError != null) 133 onError(LastUsedModel ?? Settings.Model, new Exception(LastError ?? "Все модели недоступны")); 134 }); 135 return; 136 } 137 138 InvokeOnUI(() => 139 { 140 if (onComplete != null) 141 onComplete(result); 142 }); 143 } 144 catch (Exception ex) 145 { 146 InvokeOnUI(() => 147 { 148 if (onError != null) 149 onError(LastUsedModel ?? Settings.Model, ex); 150 }); 151 } 152 }); 153 } 154 155 private static void InvokeOnUI(Action action) 156 { 157 var tray = App.TrayApp.Instance; 158 if (tray != null && !tray.IsDisposed) 159 { 160 try { if (tray.BeginInvoke(action) != null) return; } catch { } 161 } 162 // No usable UI thread (very early startup, or app shutting down). Do NOT run the 163 // callback on this background ThreadPool thread — these callbacks touch WinForms 164 // controls / open modal dialogs and would throw a cross-thread InvalidOperationException. 165 } 166 167 /// <summary> 168 /// Generate description and caption for an image asynchronously using ThreadPool 169 /// </summary> 170 public static void GenerateDescriptionAsync(Bitmap image, Action<Tuple<string, string>> onComplete, Action<Exception> onError, string previousContext = null) 171 { 172 // Use ThreadPool instead of BackgroundWorker to avoid SynchronizationContext issues 173 // when the calling form is closed before completion 174 ThreadPool.QueueUserWorkItem(state => 175 { 176 try 177 { 178 var result = GenerateDescription(image, previousContext); 179 180 // If result is null, API failed - call onError instead of onComplete 181 if (result == null) 182 { 183 if (onError != null) 184 { 185 InvokeOnUI(() => onError(new Exception(LastError ?? "Ошибка генерации описания"))); 186 } 187 return; 188 } 189 190 if (onComplete != null) 191 { 192 InvokeOnUI(() => onComplete(result)); 193 } 194 } 195 catch (Exception ex) 196 { 197 if (onError != null) 198 { 199 InvokeOnUI(() => onError(ex)); 200 } 201 } 202 }); 203 } 204 205 /// <summary> 206 /// Last error message for debugging 207 /// </summary> 208 public static string LastError { get; private set; } 209 210 /// <summary> 211 /// Last response for debugging 212 /// </summary> 213 public static string LastResponse { get; private set; } 214 215 private static void Log(string message) 216 { 217 Logger.Log("gemini", message); 218 } 219 220 /// <summary> 221 /// Generate description with automatic fallback to other models on 429 222 /// Skips models marked as Broken (red), uses Working (green) and RateLimited (yellow) 223 /// Supports API key rotation when all models are exhausted 224 /// </summary> 225 public static Tuple<string, string, string> GenerateDescriptionWithFallback(Bitmap image, string previousContext = null, Action<string> onModelSwitch = null) 226 { 227 LastError = null; 228 LastResponse = null; 229 230 if (!IsConfigured) 231 { 232 LastError = "API key not configured"; 233 Log(LastError); 234 return null; 235 } 236 237 // Convert image once 238 string base64Image = ImageToBase64(image); 239 Log("Image converted, base64 length: " + base64Image.Length); 240 241 // Track which API keys we've tried (to detect full circle) 242 int startingKeyIndex = Settings.CurrentApiKeyIndex; 243 bool triedAllKeys = false; 244 int keyRotationCount = 0; 245 246 while (!triedAllKeys) 247 { 248 Log("=== Trying API key #" + Settings.CurrentApiKeyIndex + " ==="); 249 250 // Build list of models to try 251 // Priority: 1) Last working model, 2) User's selected model, 3) All fallbacks 252 // Skip models marked as Broken (red) 253 var modelsToTry = new List<string>(); 254 255 // First: try last working model (if usable) 256 string lastWorking = Settings.LastWorkingModel; 257 if (!string.IsNullOrEmpty(lastWorking) && Settings.IsModelUsable(lastWorking)) 258 modelsToTry.Add(lastWorking); 259 260 // Second: user's preferred model (if different and usable) 261 string preferredModel = Settings.Model; 262 if (!string.IsNullOrEmpty(preferredModel) && !modelsToTry.Contains(preferredModel) && Settings.IsModelUsable(preferredModel)) 263 modelsToTry.Add(preferredModel); 264 265 // Third: all fallback models (excluding broken) 266 foreach (var model in FallbackModels) 267 { 268 if (!modelsToTry.Contains(model) && Settings.IsModelUsable(model)) 269 modelsToTry.Add(model); 270 } 271 272 Log("Models to try (excluding broken): " + string.Join(", ", modelsToTry.ToArray())); 273 274 if (modelsToTry.Count == 0) 275 { 276 Log("No usable models on this key, trying next key..."); 277 // No usable models - all are broken or rate limited, try next key 278 if (!TryNextApiKey(startingKeyIndex, ref keyRotationCount, ref triedAllKeys)) 279 break; 280 continue; 281 } 282 283 // Try each model 284 bool allModelsRateLimited = true; 285 foreach (var model in modelsToTry) 286 { 287 Log("Trying model: " + model); 288 LastUsedModel = model; 289 290 if (onModelSwitch != null) 291 { 292 InvokeOnUI(() => onModelSwitch(model)); 293 } 294 295 var result = TryGenerateWithModel(model, base64Image, previousContext); 296 297 if (result != null) 298 { 299 Log("Success with model: " + model); 300 // Mark as working (green) and save as last working 301 Settings.SetModelStatus(model, ModelStatusType.Working); 302 Settings.LastWorkingModel = model; 303 TitlePageData.Instance.Save(); 304 return new Tuple<string, string, string>(result.Item1, result.Item2, model); 305 } 306 307 // Check if it was a 429 error - mark yellow, continue to next model 308 if (LastError != null && (LastError.Contains("429") || LastError.Contains("RESOURCE_EXHAUSTED"))) 309 { 310 Log("Rate limited on " + model + ", marking yellow, trying next..."); 311 Settings.SetModelStatus(model, ModelStatusType.RateLimited, LastError); 312 TitlePageData.Instance.Save(); 313 continue; 314 } 315 316 // Check if model responded but without markers - don't mark as broken, just try next 317 if (LastError != null && LastError.Contains("no %CAPTION% or %PICNAME% markers found")) 318 { 319 Log("Model " + model + " responded without markers, trying next model..."); 320 continue; 321 } 322 323 // Other error - mark as broken (red), continue to next model 324 Log("Non-429 error on " + model + ": " + LastError + " - marking as broken"); 325 Settings.SetModelStatus(model, ModelStatusType.Broken, LastError); 326 TitlePageData.Instance.Save(); 327 allModelsRateLimited = false; // We have a broken model, not just rate limited 328 } 329 330 // All models on this key failed 331 // If all were rate limited (not broken), try next API key 332 if (allModelsRateLimited && Settings.ApiKeys != null && Settings.ApiKeys.Count > 1) 333 { 334 Log("All models rate limited on this key, switching to next key..."); 335 if (!TryNextApiKey(startingKeyIndex, ref keyRotationCount, ref triedAllKeys)) 336 break; 337 // Reset rate limited statuses for new key (they might have different limits) 338 // but keep broken statuses (model issues are likely API-wide) 339 continue; 340 } 341 else 342 { 343 // Can't switch keys or have broken models 344 break; 345 } 346 } 347 348 if (triedAllKeys) 349 { 350 LastError = "Все API ключи и модели исчерпали лимиты. Попробуйте позже."; 351 } 352 else 353 { 354 LastError = "Все модели недоступны (лимиты исчерпаны или сломаны)"; 355 } 356 return null; 357 } 358 359 /// <summary> 360 /// Try to switch to next API key 361 /// Returns false if we've completed a full circle 362 /// </summary> 363 private static bool TryNextApiKey(int startingKeyIndex, ref int keyRotationCount, ref bool triedAllKeys) 364 { 365 if (Settings.ApiKeys == null || Settings.ApiKeys.Count <= 1) 366 { 367 triedAllKeys = true; 368 return false; 369 } 370 371 Settings.SwitchToNextApiKey(); 372 TitlePageData.Instance.Save(); 373 374 // Check if we've done a full circle 375 if (Settings.CurrentApiKeyIndex == startingKeyIndex) 376 { 377 keyRotationCount++; 378 if (keyRotationCount >= 1) // One full circle 379 { 380 triedAllKeys = true; 381 return false; 382 } 383 } 384 385 return true; 386 } 387 388 /// <summary> 389 /// Try to generate with a specific model 390 /// </summary> 391 private static Tuple<string, string> TryGenerateWithModel(string model, string base64Image, string previousContext) 392 { 393 try 394 { 395 string requestJson = BuildRequestJson(base64Image, previousContext, model); 396 string apiUrl = GetApiUrl(model); 397 398 Log("Sending request to: " + apiUrl); 399 string response = SendRequest(requestJson, apiUrl); 400 LastResponse = response; 401 402 if (string.IsNullOrEmpty(response)) 403 { 404 LastError = "Empty response from API"; 405 return null; 406 } 407 408 Log("Response received, length: " + response.Length); 409 Log("Response: " + (response.Length > 500 ? response.Substring(0, 500) + "..." : response)); 410 411 var result = ParseResponse(response); 412 Log("Parsed - Description: [" + (result != null ? result.Item1 : "null") + "], Caption: [" + (result != null ? result.Item2 : "null") + "]"); 413 414 if (result == null) 415 { 416 LastError = "Failed to parse response"; 417 return null; 418 } 419 420 // Check if we got actual content (not just empty strings) 421 if (string.IsNullOrEmpty(result.Item1) && string.IsNullOrEmpty(result.Item2)) 422 { 423 LastError = "Response parsed but no %CAPTION% or %PICNAME% markers found"; 424 Log(LastError); 425 return null; 426 } 427 428 return result; 429 } 430 catch (WebException ex) 431 { 432 LastError = "WebException: " + ex.Message + " | Status: " + ex.Status; 433 if (ex.Response != null) 434 { 435 try 436 { 437 using (var reader = new StreamReader(ex.Response.GetResponseStream())) 438 { 439 string errorBody = reader.ReadToEnd(); 440 LastError += " | Body: " + errorBody; 441 Log("API error response: " + errorBody); 442 } 443 } 444 catch { } 445 } 446 Log(LastError); 447 return null; 448 } 449 catch (Exception ex) 450 { 451 LastError = ex.GetType().Name + ": " + ex.Message; 452 Log(LastError); 453 return null; 454 } 455 } 456 457 /// <summary> 458 /// Generate description and caption for an image (synchronous, single model) 459 /// </summary> 460 public static Tuple<string, string> GenerateDescription(Bitmap image, string previousContext = null) 461 { 462 LastError = null; 463 LastResponse = null; 464 465 Log("=== Starting generation ==="); 466 Log("API Key length: " + (ApiKey != null ? ApiKey.Length.ToString() : "null")); 467 Log("Previous context: " + (previousContext != null ? previousContext.Substring(0, Math.Min(50, previousContext.Length)) + "..." : "null")); 468 469 if (!IsConfigured) 470 { 471 LastError = "API key not configured"; 472 Log(LastError); 473 return null; 474 } 475 476 try 477 { 478 Log("Starting image conversion..."); 479 string base64Image = ImageToBase64(image); 480 Log("Image converted, base64 length: " + base64Image.Length); 481 482 string requestJson = BuildRequestJson(base64Image, previousContext, Settings.Model); 483 Log("Request JSON built, length: " + requestJson.Length); 484 485 string apiUrl = GetApiUrl(Settings.Model); 486 Log("Sending request to API: " + apiUrl); 487 string response = SendRequest(requestJson, apiUrl); 488 LastResponse = response; 489 Log("Response received, length: " + (response != null ? response.Length.ToString() : "null")); 490 491 if (string.IsNullOrEmpty(response)) 492 { 493 LastError = "Empty response from API"; 494 Log(LastError); 495 return null; 496 } 497 498 Log("Full response: " + response); 499 500 var result = ParseResponse(response); 501 if (result == null) 502 { 503 LastError = "Failed to parse response"; 504 Log(LastError); 505 } 506 else 507 { 508 Log("Parsed - Description: [" + (result.Item1 ?? "null") + "], Caption: [" + (result.Item2 ?? "null") + "]"); 509 } 510 return result; 511 } 512 catch (WebException ex) 513 { 514 LastError = "WebException: " + ex.Message + " | Status: " + ex.Status; 515 if (ex.Response != null) 516 { 517 try 518 { 519 using (var reader = new StreamReader(ex.Response.GetResponseStream())) 520 { 521 string errorBody = reader.ReadToEnd(); 522 LastError += " | Body: " + errorBody; 523 Log("API error response: " + errorBody); 524 } 525 } 526 catch { } 527 } 528 Log(LastError); 529 return null; 530 } 531 catch (Exception ex) 532 { 533 LastError = ex.GetType().Name + ": " + ex.Message + " | Stack: " + ex.StackTrace; 534 Log(LastError); 535 return null; 536 } 537 } 538 539 private static string ImageToBase64(Bitmap image) 540 { 541 using (var ms = new MemoryStream()) 542 { 543 image.Save(ms, ImageFormat.Png); 544 return Convert.ToBase64String(ms.ToArray()); 545 } 546 } 547 548 private static string BuildRequestJson(string base64Image, string previousContext, string model) 549 { 550 // Get system instruction from settings 551 string systemInstruction = Settings.SystemInstruction; 552 if (string.IsNullOrEmpty(systemInstruction)) 553 { 554 systemInstruction = "Опиши изображение."; 555 } 556 557 // Add previous context if available 558 if (!string.IsNullOrEmpty(previousContext)) 559 { 560 systemInstruction += "\n\nВот, что было в прошлом описании. Учитывай контекст:\n" + previousContext; 561 } 562 563 // Escape for JSON 564 systemInstruction = EscapeJson(systemInstruction); 565 566 // Get parameters from settings 567 int thinkingBudget = Settings.ThinkingBudget; 568 string mediaResolution = Settings.MediaResolution; 569 if (string.IsNullOrEmpty(mediaResolution)) 570 mediaResolution = "MEDIA_RESOLUTION_LOW"; 571 572 // Check if model supports thinking 573 bool supportsThinking = model.Contains("2.5") || model.Contains("3-") || model.Contains("exp"); 574 575 string generationConfig; 576 if (supportsThinking && thinkingBudget > 0) 577 { 578 generationConfig = "\"generationConfig\": {" + 579 "\"thinkingConfig\": {\"thinkingBudget\": " + thinkingBudget + "}," + 580 "\"maxOutputTokens\": 8192," + 581 "\"mediaResolution\": \"" + mediaResolution + "\"" + 582 "}"; 583 } 584 else 585 { 586 generationConfig = "\"generationConfig\": {" + 587 "\"maxOutputTokens\": 8192," + 588 "\"mediaResolution\": \"" + mediaResolution + "\"" + 589 "}"; 590 } 591 592 string json = "{" + 593 "\"contents\": [{" + 594 "\"role\": \"user\"," + 595 "\"parts\": [" + 596 "{\"inline_data\": {\"mime_type\": \"image/png\", \"data\": \"" + base64Image + "\"}}," + 597 "{\"text\": \"Опиши это изображение и дай ему название.\"}" + 598 "]" + 599 "}]," + 600 generationConfig + "," + 601 "\"systemInstruction\": {" + 602 "\"parts\": [{\"text\": \"" + systemInstruction + "\"}]" + 603 "}" + 604 "}"; 605 return json; 606 } 607 608 private static string EscapeJson(string s) 609 { 610 if (string.IsNullOrEmpty(s)) return s; 611 612 var sb = new StringBuilder(); 613 foreach (char c in s) 614 { 615 switch (c) 616 { 617 case '"': sb.Append("\\\""); break; 618 case '\\': sb.Append("\\\\"); break; 619 case '\b': sb.Append("\\b"); break; 620 case '\f': sb.Append("\\f"); break; 621 case '\n': sb.Append("\\n"); break; 622 case '\r': sb.Append("\\r"); break; 623 case '\t': sb.Append("\\t"); break; 624 default: sb.Append(c); break; 625 } 626 } 627 return sb.ToString(); 628 } 629 630 private static string SendRequest(string requestJson, string apiUrl) 631 { 632 string url = apiUrl + "?key=" + ApiKey; 633 634 var request = (HttpWebRequest)WebRequest.Create(url); 635 request.Method = "POST"; 636 request.ContentType = "application/json"; 637 request.Timeout = 60000; // 60 seconds for slower models 638 639 byte[] data = Encoding.UTF8.GetBytes(requestJson); 640 request.ContentLength = data.Length; 641 642 using (var stream = request.GetRequestStream()) 643 { 644 stream.Write(data, 0, data.Length); 645 } 646 647 try 648 { 649 using (var response = (HttpWebResponse)request.GetResponse()) 650 using (var reader = new StreamReader(response.GetResponseStream(), Encoding.UTF8)) 651 { 652 return reader.ReadToEnd(); 653 } 654 } 655 catch (WebException ex) 656 { 657 if (ex.Response != null) 658 { 659 using (var reader = new StreamReader(ex.Response.GetResponseStream())) 660 { 661 string errorResponse = reader.ReadToEnd(); 662 Log("API error response body: " + errorResponse); 663 LastError = "API Error: " + errorResponse; 664 } 665 } 666 throw; 667 } 668 } 669 670 private static Tuple<string, string> ParseResponse(string json) 671 { 672 // Find "text": "..." in candidates[0].content.parts[0].text 673 // Use LastIndexOf to get the last text part (skip thinking text in models with thinking) 674 string textMarker = "\"text\":"; 675 int textIndex = json.LastIndexOf(textMarker); 676 if (textIndex < 0) return null; 677 678 int startQuote = json.IndexOf('"', textIndex + textMarker.Length); 679 if (startQuote < 0) return null; 680 681 int endQuote = FindClosingQuote(json, startQuote + 1); 682 if (endQuote < 0) return null; 683 684 string text = json.Substring(startQuote + 1, endQuote - startQuote - 1); 685 text = UnescapeJson(text); 686 687 Log("Extracted text from response: " + (text.Length > 200 ? text.Substring(0, 200) + "..." : text)); 688 689 // Parse %CAPTION% and %PICNAME% markers 690 string description = ExtractBetweenMarkers(text, "%CAPTION%", "%CAPTION%"); 691 string caption = ExtractBetweenMarkers(text, "%PICNAME%", "%PICNAME%"); 692 693 // Fallback 1: if marker was opened but not closed (e.g. MAX_TOKENS), take everything after it 694 if (description == null && text.Contains("%CAPTION%")) 695 { 696 int idx = text.IndexOf("%CAPTION%") + "%CAPTION%".Length; 697 description = text.Substring(idx); 698 Log("Fallback: took text after unclosed %CAPTION% marker"); 699 } 700 if (caption == null && text.Contains("%PICNAME%")) 701 { 702 int idx = text.IndexOf("%PICNAME%") + "%PICNAME%".Length; 703 string remaining = text.Substring(idx); 704 int nl = remaining.IndexOf('\n'); 705 caption = nl >= 0 ? remaining.Substring(0, nl) : remaining; 706 Log("Fallback: took text after unclosed %PICNAME% marker"); 707 } 708 709 // Fallback 2: no markers at all - model ignored the system instruction 710 // Use the full text as description and try to extract caption from patterns 711 if (string.IsNullOrEmpty(description) && string.IsNullOrEmpty(caption) && text.Length > 10) 712 { 713 Log("No markers found, using fallback text parsing"); 714 715 // Try to find caption from common patterns the model might use 716 string[] captionPatterns = new string[] 717 { 718 "**Название изображения:**", 719 "**Название:**", 720 "Название изображения:", 721 "Название:", 722 }; 723 724 foreach (var pattern in captionPatterns) 725 { 726 int patIdx = text.IndexOf(pattern); 727 if (patIdx >= 0) 728 { 729 int captionStart = patIdx + pattern.Length; 730 int captionEnd = text.IndexOf('\n', captionStart); 731 if (captionEnd < 0) captionEnd = text.Length; 732 caption = text.Substring(captionStart, captionEnd - captionStart) 733 .Trim().Trim('"').Trim('«', '»'); 734 735 // Use text before the caption pattern as description 736 description = text.Substring(0, patIdx).Trim(); 737 Log("Fallback: extracted caption from pattern '" + pattern + "': " + caption); 738 break; 739 } 740 } 741 742 // If still no caption found, use entire text as description 743 if (string.IsNullOrEmpty(description)) 744 { 745 description = text.Trim(); 746 Log("Fallback: using entire response text as description"); 747 } 748 } 749 750 // Clean up - remove markdown bold markers 751 if (!string.IsNullOrEmpty(description)) 752 description = description.Trim().Replace("**", ""); 753 if (!string.IsNullOrEmpty(caption)) 754 caption = caption.Trim().Replace("**", ""); 755 756 return new Tuple<string, string>(description ?? "", caption ?? ""); 757 } 758 759 private static int FindClosingQuote(string s, int start) 760 { 761 for (int i = start; i < s.Length; i++) 762 { 763 if (s[i] == '"' && (i == 0 || s[i - 1] != '\\')) 764 return i; 765 } 766 return -1; 767 } 768 769 private static string UnescapeJson(string s) 770 { 771 if (string.IsNullOrEmpty(s)) return s; 772 773 var sb = new StringBuilder(); 774 for (int i = 0; i < s.Length; i++) 775 { 776 if (s[i] == '\\' && i + 1 < s.Length) 777 { 778 char next = s[i + 1]; 779 switch (next) 780 { 781 case '"': sb.Append('"'); i++; break; 782 case '\\': sb.Append('\\'); i++; break; 783 case 'b': sb.Append('\b'); i++; break; 784 case 'f': sb.Append('\f'); i++; break; 785 case 'n': sb.Append('\n'); i++; break; 786 case 'r': sb.Append('\r'); i++; break; 787 case 't': sb.Append('\t'); i++; break; 788 default: sb.Append(s[i]); break; 789 } 790 } 791 else 792 { 793 sb.Append(s[i]); 794 } 795 } 796 return sb.ToString(); 797 } 798 799 private static string ExtractBetweenMarkers(string text, string startMarker, string endMarker) 800 { 801 int start = text.IndexOf(startMarker); 802 if (start < 0) return null; 803 804 start += startMarker.Length; 805 int end = text.IndexOf(endMarker, start); 806 if (end < 0) return null; 807 808 return text.Substring(start, end - start); 809 } 810 811 // ==================== Batch Text Rephrasing ==================== 812 813 /// <summary> 814 /// Rephrase multiple texts asynchronously via Gemini API 815 /// </summary> 816 public static void RephraseTextsAsync(List<string> texts, Action<List<string>> onComplete, Action<string> onError) 817 { 818 ThreadPool.QueueUserWorkItem(state => 819 { 820 try 821 { 822 var result = RephraseTextsWithFallback(texts); 823 if (result == null) 824 { 825 InvokeOnUI(() => 826 { 827 if (onError != null) 828 onError(LastError ?? "Не удалось перефразировать тексты"); 829 }); 830 return; 831 } 832 833 InvokeOnUI(() => 834 { 835 if (onComplete != null) 836 onComplete(result); 837 }); 838 } 839 catch (Exception ex) 840 { 841 InvokeOnUI(() => 842 { 843 if (onError != null) 844 onError(ex.Message); 845 }); 846 } 847 }); 848 } 849 850 /// <summary> 851 /// Rephrase texts with model fallback (same logic as GenerateDescriptionWithFallback) 852 /// </summary> 853 public static List<string> RephraseTextsWithFallback(List<string> texts) 854 { 855 LastError = null; 856 LastResponse = null; 857 858 if (!IsConfigured) 859 { 860 LastError = "API key not configured"; 861 Log(LastError); 862 return null; 863 } 864 865 if (texts == null || texts.Count == 0) 866 { 867 LastError = "No texts to rephrase"; 868 return null; 869 } 870 871 Log("=== RephraseTextsWithFallback: " + texts.Count + " texts ==="); 872 873 int startingKeyIndex = Settings.CurrentApiKeyIndex; 874 bool triedAllKeys = false; 875 int keyRotationCount = 0; 876 877 while (!triedAllKeys) 878 { 879 var modelsToTry = new List<string>(); 880 881 string lastWorking = Settings.LastWorkingModel; 882 if (!string.IsNullOrEmpty(lastWorking) && Settings.IsModelUsable(lastWorking)) 883 modelsToTry.Add(lastWorking); 884 885 string preferredModel = Settings.Model; 886 if (!string.IsNullOrEmpty(preferredModel) && !modelsToTry.Contains(preferredModel) && Settings.IsModelUsable(preferredModel)) 887 modelsToTry.Add(preferredModel); 888 889 foreach (var model in FallbackModels) 890 { 891 if (!modelsToTry.Contains(model) && Settings.IsModelUsable(model)) 892 modelsToTry.Add(model); 893 } 894 895 if (modelsToTry.Count == 0) 896 { 897 if (!TryNextApiKey(startingKeyIndex, ref keyRotationCount, ref triedAllKeys)) 898 break; 899 continue; 900 } 901 902 foreach (var model in modelsToTry) 903 { 904 Log("Rephrase: trying model " + model); 905 LastUsedModel = model; 906 907 var result = TryRephraseWithModel(model, texts); 908 if (result != null && result.Count == texts.Count) 909 { 910 Log("Rephrase success with model: " + model); 911 Settings.SetModelStatus(model, ModelStatusType.Working); 912 Settings.LastWorkingModel = model; 913 TitlePageData.Instance.Save(); 914 return result; 915 } 916 917 if (LastError != null && (LastError.Contains("429") || LastError.Contains("RESOURCE_EXHAUSTED"))) 918 { 919 Settings.SetModelStatus(model, ModelStatusType.RateLimited, LastError); 920 TitlePageData.Instance.Save(); 921 continue; 922 } 923 924 if (LastError != null && LastError.Contains("markers")) 925 { 926 Log("Model " + model + " responded without proper markers, trying next..."); 927 continue; 928 } 929 930 Settings.SetModelStatus(model, ModelStatusType.Broken, LastError); 931 TitlePageData.Instance.Save(); 932 } 933 934 if (Settings.ApiKeys != null && Settings.ApiKeys.Count > 1) 935 { 936 if (!TryNextApiKey(startingKeyIndex, ref keyRotationCount, ref triedAllKeys)) 937 break; 938 } 939 else 940 { 941 break; 942 } 943 } 944 945 LastError = LastError ?? "Все модели недоступны"; 946 return null; 947 } 948 949 /// <summary> 950 /// Try to rephrase texts with a specific model 951 /// </summary> 952 private static List<string> TryRephraseWithModel(string model, List<string> texts) 953 { 954 try 955 { 956 string requestJson = BuildRephraseRequestJson(texts, model); 957 string apiUrl = GetApiUrl(model); 958 959 Log("Rephrase request to: " + apiUrl + " (" + texts.Count + " texts)"); 960 string response = SendRequest(requestJson, apiUrl); 961 LastResponse = response; 962 963 if (string.IsNullOrEmpty(response)) 964 { 965 LastError = "Empty response from API"; 966 return null; 967 } 968 969 Log("Rephrase response length: " + response.Length); 970 971 var result = ParseRephraseResponse(response, texts.Count); 972 if (result == null || result.Count != texts.Count) 973 { 974 LastError = "Failed to parse rephrase response: expected " + texts.Count + " items, got " + (result != null ? result.Count.ToString() : "null") + ". Missing markers."; 975 Log(LastError); 976 return null; 977 } 978 979 return result; 980 } 981 catch (WebException ex) 982 { 983 LastError = "WebException: " + ex.Message; 984 if (ex.Response != null) 985 { 986 try 987 { 988 using (var reader = new StreamReader(ex.Response.GetResponseStream())) 989 { 990 string errorBody = reader.ReadToEnd(); 991 LastError += " | Body: " + errorBody; 992 } 993 } 994 catch { } 995 } 996 Log(LastError); 997 return null; 998 } 999 catch (Exception ex) 1000 { 1001 LastError = ex.GetType().Name + ": " + ex.Message; 1002 Log(LastError); 1003 return null; 1004 } 1005 } 1006 1007 /// <summary> 1008 /// Build a text-only rephrase request JSON (no image) 1009 /// </summary> 1010 private static string BuildRephraseRequestJson(List<string> texts, string model) 1011 { 1012 string systemInstruction = "Ты — помощник для перефразирования текстов. Перефразируй каждый текст, " + 1013 "сохраняя его смысл, длину и стиль. Не добавляй ничего лишнего. " + 1014 "Каждый перефразированный текст оберни маркерами %ITEM_N%...%ITEM_N% где N — номер текста (начиная с 1)."; 1015 systemInstruction = EscapeJson(systemInstruction); 1016 1017 var sb = new StringBuilder(); 1018 for (int i = 0; i < texts.Count; i++) 1019 { 1020 sb.Append((i + 1).ToString() + ". " + texts[i]); 1021 if (i < texts.Count - 1) sb.Append("\n\n"); 1022 } 1023 string userMessage = EscapeJson(sb.ToString()); 1024 1025 bool supportsThinking = model.Contains("2.5") || model.Contains("3-") || model.Contains("exp"); 1026 int thinkingBudget = Settings.ThinkingBudget; 1027 1028 string generationConfig; 1029 if (supportsThinking && thinkingBudget > 0) 1030 { 1031 generationConfig = "\"generationConfig\": {" + 1032 "\"thinkingConfig\": {\"thinkingBudget\": " + thinkingBudget + "}," + 1033 "\"maxOutputTokens\": 8192" + 1034 "}"; 1035 } 1036 else 1037 { 1038 generationConfig = "\"generationConfig\": {" + 1039 "\"maxOutputTokens\": 8192" + 1040 "}"; 1041 } 1042 1043 string json = "{" + 1044 "\"contents\": [{" + 1045 "\"role\": \"user\"," + 1046 "\"parts\": [" + 1047 "{\"text\": \"Перефразируй следующие тексты:\\n\\n" + userMessage + "\"}" + 1048 "]" + 1049 "}]," + 1050 generationConfig + "," + 1051 "\"systemInstruction\": {" + 1052 "\"parts\": [{\"text\": \"" + systemInstruction + "\"}]" + 1053 "}" + 1054 "}"; 1055 return json; 1056 } 1057 1058 /// <summary> 1059 /// Parse rephrase response, extracting %ITEM_N% markers 1060 /// </summary> 1061 private static List<string> ParseRephraseResponse(string json, int expectedCount) 1062 { 1063 // Extract text from JSON (same as ParseResponse) 1064 string textMarker = "\"text\":"; 1065 int textIndex = json.LastIndexOf(textMarker); 1066 if (textIndex < 0) return null; 1067 1068 int startQuote = json.IndexOf('"', textIndex + textMarker.Length); 1069 if (startQuote < 0) return null; 1070 1071 int endQuote = FindClosingQuote(json, startQuote + 1); 1072 if (endQuote < 0) return null; 1073 1074 string text = json.Substring(startQuote + 1, endQuote - startQuote - 1); 1075 text = UnescapeJson(text); 1076 1077 Log("Rephrase extracted text: " + (text.Length > 300 ? text.Substring(0, 300) + "..." : text)); 1078 1079 var results = new List<string>(); 1080 1081 // Try to extract %ITEM_N% markers 1082 for (int i = 1; i <= expectedCount; i++) 1083 { 1084 string marker = "%ITEM_" + i + "%"; 1085 string extracted = ExtractBetweenMarkers(text, marker, marker); 1086 if (extracted != null) 1087 { 1088 results.Add(extracted.Trim().Replace("**", "")); 1089 } 1090 else 1091 { 1092 break; // Markers not found, try fallback 1093 } 1094 } 1095 1096 if (results.Count == expectedCount) 1097 return results; 1098 1099 // Fallback: try splitting by numbered patterns 1100 Log("Markers not found, trying numbered pattern fallback"); 1101 results.Clear(); 1102 1103 for (int i = 1; i <= expectedCount; i++) 1104 { 1105 string startPattern = i + "."; 1106 string endPattern = (i + 1) + "."; 1107 1108 int startIdx = text.IndexOf(startPattern); 1109 if (startIdx < 0) break; 1110 1111 startIdx += startPattern.Length; 1112 int endIdx; 1113 1114 if (i < expectedCount) 1115 { 1116 endIdx = text.IndexOf(endPattern, startIdx); 1117 if (endIdx < 0) endIdx = text.Length; 1118 } 1119 else 1120 { 1121 endIdx = text.Length; 1122 } 1123 1124 string item = text.Substring(startIdx, endIdx - startIdx).Trim().Replace("**", ""); 1125 if (item.Length > 0) 1126 results.Add(item); 1127 } 1128 1129 if (results.Count == expectedCount) 1130 { 1131 Log("Numbered fallback succeeded"); 1132 return results; 1133 } 1134 1135 Log("Fallback failed: got " + results.Count + " items, expected " + expectedCount); 1136 return null; 1137 } 1138 } 1139}