windowcapture
исходный код / Integration/GeminiIntegration.cs

GeminiIntegration.cs

1139 строк · 44,699 байт · модуль Integration
   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}