using System; using System.IO; using System.IO.Compression; // Добавлено (или уже было) для ZipFile using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; using System.Windows.Forms; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using Markdig; using HtmlToOpenXml; // Для конвертера HTML в DOCX using System.Collections.Generic; // Для IList<> using DocumentFormat.OpenXml; // Для OpenXmlCompositeElement using System.Diagnostics; // <--- ДОБАВЛЕНО для Debug.WriteLine namespace GiteaDocGenerator { public partial class Form1 : Form { private string _tempDir = string.Empty; // Для хранения скачанного кода public Form1() { InitializeComponent(); } // --- Обработчики UI --- private void btnSelectSample_Click(object sender, EventArgs e) { using (OpenFileDialog ofd = new OpenFileDialog()) { ofd.Filter = "Word Documents (*.docx)|*.docx"; ofd.Title = "Выберите файл-пример DOCX"; if (ofd.ShowDialog() == DialogResult.OK) { txtSampleDocx.Text = ofd.FileName; } } } private async void btnGenerate_Click(object sender, EventArgs e) { string repoUrl = txtRepoUrl.Text.Trim(); string sampleDocxPath = txtSampleDocx.Text; string apiKey = txtApiKey.Text.Trim(); // **НЕБЕЗОПАСНО!** if (string.IsNullOrWhiteSpace(repoUrl) || !Uri.IsWellFormedUriString(repoUrl, UriKind.Absolute)) { MessageBox.Show("Пожалуйста, введите корректный URL репозитория Gitea.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (string.IsNullOrWhiteSpace(sampleDocxPath) || !File.Exists(sampleDocxPath)) { MessageBox.Show("Пожалуйста, выберите существующий файл-пример DOCX.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } if (string.IsNullOrWhiteSpace(apiKey)) { MessageBox.Show("Пожалуйста, введите ваш Google AI API Key.", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // Блокируем UI на время работы SetUiEnabled(false); lblStatus.Text = "Статус: Скачивание репозитория..."; try { // 1. Скачивание и распаковка репозитория _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_tempDir); string zipPath = await DownloadRepoAsync(repoUrl, _tempDir); if (string.IsNullOrEmpty(zipPath)) return; // Ошибка уже показана в DownloadRepoAsync lblStatus.Text = "Статус: Распаковка архива..."; string extractedPath = UnzipRepo(zipPath, _tempDir); File.Delete(zipPath); // Удаляем zip после распаковки // Находим папку с кодом внутри (обычно имя_репо-ветка) string repoSourcePath = Directory.GetDirectories(extractedPath).FirstOrDefault(); if (repoSourcePath == null) { throw new DirectoryNotFoundException("Не удалось найти папку с исходным кодом после распаковки."); } // 2. Подготовка данных для ИИ lblStatus.Text = "Статус: Чтение кода и структуры примера..."; string codeContent = ReadCodeFiles(repoSourcePath); // Читаем код string sampleStructure = ExtractStructureFromDocx(sampleDocxPath); // Читаем структуру DOCX if (string.IsNullOrWhiteSpace(codeContent)) { MessageBox.Show("Не удалось прочитать файлы кода из репозитория. Возможно, репозиторий пуст или содержит неподдерживаемые файлы.", "Предупреждение", MessageBoxButtons.OK, MessageBoxIcon.Warning); } // 3. Взаимодействие с Gemini API lblStatus.Text = "Статус: Обращение к Google Gemini API..."; string markdownResult = await CallGeminiApiAsync(apiKey, sampleStructure, codeContent); if (string.IsNullOrWhiteSpace(markdownResult)) { lblStatus.Text = "Статус: Ошибка API."; MessageBox.Show("Получен пустой ответ от Gemini API.", "Ошибка API", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } // 4. Создание DOCX из Markdown lblStatus.Text = "Статус: Создание DOCX файла..."; string outputDocxPath = ShowSaveDialog(); if (!string.IsNullOrEmpty(outputDocxPath)) { await CreateDocxFromMarkdown(markdownResult, outputDocxPath, sampleDocxPath); lblStatus.Text = $"Статус: Готово! Файл сохранен в {outputDocxPath}"; MessageBox.Show($"Документация успешно сгенерирована и сохранена:\n{outputDocxPath}", "Успех", MessageBoxButtons.OK, MessageBoxIcon.Information); } else { lblStatus.Text = "Статус: Генерация отменена."; } } catch (Exception ex) { lblStatus.Text = "Статус: Ошибка!"; string errorMessage = $"Произошла ошибка: {ex.Message}"; if (ex.InnerException != null) { errorMessage += $"\n\nВнутренняя ошибка: {ex.InnerException.Message}\n\n{ex.InnerException.StackTrace}"; } errorMessage += $"\n\nStackTrace:\n{ex.StackTrace}"; // Если в сообщении уже есть лог Markdown/HTML (добавленный в CreateDocxFromMarkdown), // он будет включен здесь автоматически. MessageBox.Show(errorMessage, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { SetUiEnabled(true); CleanUpTempDir(); } } private void SetUiEnabled(bool enabled) { txtRepoUrl.Enabled = enabled; txtSampleDocx.Enabled = enabled; btnSelectSample.Enabled = enabled; btnGenerate.Enabled = enabled; txtApiKey.Enabled = enabled; } private void CleanUpTempDir() { if (!string.IsNullOrEmpty(_tempDir) && Directory.Exists(_tempDir)) { try { Directory.Delete(_tempDir, true); } catch (Exception ex) { Console.WriteLine($"Не удалось удалить временную папку {_tempDir}: {ex.Message}"); } _tempDir = string.Empty; } } // --- Логика работы с Gitea --- private async Task DownloadRepoAsync(string repoUrl, string downloadDir) { Uri repoUri = new Uri(repoUrl); string zipUrlMaster = $"{repoUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/archive/master.zip"; string zipUrlMain = $"{repoUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/archive/main.zip"; string zipPath = Path.Combine(downloadDir, "repo.zip"); using (HttpClient client = new HttpClient()) { client.DefaultRequestHeaders.Add("User-Agent", "GiteaDocGeneratorApp"); HttpResponseMessage response = null; try { response = await client.GetAsync(zipUrlMain); if (!response.IsSuccessStatusCode) { lblStatus.Text = "Статус: Не удалось скачать main.zip, пробую master.zip..."; response.Dispose(); response = await client.GetAsync(zipUrlMaster); } response.EnsureSuccessStatusCode(); using (var fs = new FileStream(zipPath, FileMode.CreateNew)) { await response.Content.CopyToAsync(fs); } return zipPath; } catch (HttpRequestException httpEx) { string message = $"Ошибка скачивания репозитория: {httpEx.Message}"; if (response?.StatusCode == System.Net.HttpStatusCode.NotFound) { message += "\nУбедитесь, что URL верный, репозиторий публичный и содержит ветку 'main' или 'master'."; } else if (response != null) { message += $"\nКод ответа: {response.StatusCode}"; } MessageBox.Show(message, "Ошибка скачивания", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } finally { response?.Dispose(); } } } private string UnzipRepo(string zipPath, string extractPath) { ZipFile.ExtractToDirectory(zipPath, extractPath); return extractPath; } // --- Подготовка данных --- private string ReadCodeFiles(string sourcePath, int maxChars = 15000) { StringBuilder content = new StringBuilder(); string[] extensions = { ".cs", ".md", ".txt", ".py", ".js", ".html", ".css", ".java", ".go", ".php", "Dockerfile", ".yaml", ".yml", ".json" }; string basePathWithTrailingSlash = sourcePath.EndsWith(Path.DirectorySeparatorChar.ToString()) ? sourcePath : sourcePath + Path.DirectorySeparatorChar; Uri sourceUri = new Uri(basePathWithTrailingSlash); var readmeFiles = Directory.GetFiles(sourcePath, "README.md", SearchOption.AllDirectories).Concat(Directory.GetFiles(sourcePath, "README.txt", SearchOption.AllDirectories)); foreach (var file in readmeFiles) { if (content.Length >= maxChars) break; try { Uri fileUri = new Uri(file); string relativePath = Uri.UnescapeDataString(sourceUri.MakeRelativeUri(fileUri).ToString()).Replace('/', Path.DirectorySeparatorChar); content.AppendLine($"--- Файл: {relativePath} ---"); content.AppendLine(File.ReadAllText(file)); content.AppendLine("--- Конец файла ---"); content.AppendLine(); } catch (Exception ex) { Console.WriteLine($"Ошибка чтения файла {file}: {ex.Message}"); } } var codeFiles = Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories).Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()) && !Path.GetFileName(f).StartsWith("README.", StringComparison.OrdinalIgnoreCase)); foreach (var file in codeFiles) { if (content.Length >= maxChars) { content.AppendLine("\n[...Остальные файлы кода пропущены из-за ограничения размера...]"); break; } try { string fileContent = File.ReadAllText(file); if (!string.IsNullOrWhiteSpace(fileContent)) { Uri fileUri = new Uri(file); string relativePath = Uri.UnescapeDataString(sourceUri.MakeRelativeUri(fileUri).ToString()).Replace('/', Path.DirectorySeparatorChar); content.AppendLine($"--- Файл: {relativePath} ---"); content.AppendLine(fileContent); content.AppendLine("--- Конец файла ---"); content.AppendLine(); } } catch (Exception ex) { Console.WriteLine($"Ошибка чтения файла {file}: {ex.Message}"); } } return content.ToString(); } private string ExtractStructureFromDocx(string docxPath) { StringBuilder structure = new StringBuilder(); try { using (WordprocessingDocument wordDoc = WordprocessingDocument.Open(docxPath, false)) { Body body = wordDoc.MainDocumentPart.Document.Body; foreach (var element in body.Elements()) { string styleId = element.ParagraphProperties?.ParagraphStyleId?.Val?.Value; bool isHeading = styleId != null && styleId.StartsWith("Heading", StringComparison.OrdinalIgnoreCase) || styleId == "Заголовок"; if (isHeading) { structure.AppendLine($"{(isHeading ? "## " : "")}{element.InnerText}"); } else if (!string.IsNullOrWhiteSpace(element.InnerText)) { if (structure.Length < 2000) { structure.AppendLine(element.InnerText); } } } if (structure.Length >= 2000) { structure.AppendLine("\n[...Структура примера обрезана...]"); } } } catch (Exception ex) { MessageBox.Show($"Не удалось прочитать структуру из файла DOCX: {ex.Message}. Будет использован стандартный промпт.", "Ошибка чтения DOCX", MessageBoxButtons.OK, MessageBoxIcon.Warning); return "Заголовок 1\n\nВведение\n\nОсновная часть\n\nЗаключение"; } if (string.IsNullOrWhiteSpace(structure.ToString())) { return "Заголовок 1\n\nВведение\n\nОсновная часть\n\nЗаключение"; } return structure.ToString(); } // --- Взаимодействие с Gemini API --- private async Task CallGeminiApiAsync(string apiKey, string sampleStructure, string codeContent) { string apiUrl = $"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro-latest:generateContent?key={apiKey}"; string prompt = $@"Сгенерируй документацию для программного продукта, исходный код которого приведен ниже. Документация должна строго следовать структуре, представленной в следующем примере (используй заголовки и порядок разделов как в примере): --- НАЧАЛО СТРУКТУРЫ ПРИМЕРА --- {sampleStructure} --- КОНЕЦ СТРУКТУРЫ ПРИМЕРА --- Содержимое документации должно описывать код продукта. Если кода нет, напиши общую документацию по указанной структуре. ВЕСЬ ТВОЙ ОТВЕТ ДОЛЖЕН БЫТЬ ТОЛЬКО В ФОРМАТЕ MARKDOWN. Не добавляй никаких пояснений до или после markdown. --- НАЧАЛО КОДА ПРОДУКТА --- {codeContent} --- КОНЕЦ КОДА ПРОДУКТА --- Еще раз: ВЕСЬ ТВОЙ ОТВЕТ должен быть строго в формате Markdown."; var requestBody = new { contents = new[] { new { parts = new[] { new { text = prompt } } } }, generationConfig = new { temperature = 0.6 } }; string jsonPayload = JsonSerializer.Serialize(requestBody); using (HttpClient client = new HttpClient()) { try { var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); HttpResponseMessage response = await client.PostAsync(apiUrl, content); string responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { string errorDetails = TryExtractErrorFromJson(responseBody); throw new Exception($"Ошибка API Gemini: {response.StatusCode}. {errorDetails}"); } using (JsonDocument document = JsonDocument.Parse(responseBody)) { if (document.RootElement.TryGetProperty("candidates", out JsonElement candidates) && candidates.GetArrayLength() > 0) { if (candidates[0].TryGetProperty("content", out JsonElement responseContent) && responseContent.TryGetProperty("parts", out JsonElement parts) && parts.GetArrayLength() > 0) { if (parts[0].TryGetProperty("text", out JsonElement textElement)) { return textElement.GetString(); } } if (candidates[0].TryGetProperty("finishReason", out JsonElement reason)) { string reasonStr = reason.GetString(); string safetyInfo = ""; if (reasonStr == "SAFETY" && candidates[0].TryGetProperty("safetyRatings", out JsonElement ratings)) { safetyInfo = $" Safety Ratings: {ratings.ToString()}"; } throw new Exception($"Ответ API Gemini не содержит текст. Причина завершения: {reasonStr}.{safetyInfo}"); } } throw new Exception($"Не удалось извлечь текст из ответа API Gemini. Ответ:\n{responseBody}"); } } catch (JsonException jsonEx) { throw new Exception($"Ошибка парсинга JSON ответа от API Gemini: {jsonEx.Message}. Ответ:\n{jsonPayload}", jsonEx); } catch (Exception ex) { throw new Exception($"Ошибка при вызове Gemini API: {ex.Message}", ex); } } } private string TryExtractErrorFromJson(string jsonResponse) { if (string.IsNullOrWhiteSpace(jsonResponse)) return "(пустой ответ)"; try { using (JsonDocument document = JsonDocument.Parse(jsonResponse)) { if (document.RootElement.TryGetProperty("error", out JsonElement errorElement)) { if (errorElement.TryGetProperty("message", out JsonElement messageElement)) { return messageElement.GetString(); } return errorElement.ToString(); } } } catch (JsonException) { } return jsonResponse; } // --- Создание DOCX --- private string ShowSaveDialog() { using (SaveFileDialog sfd = new SaveFileDialog()) { sfd.Filter = "Word Document (*.docx)|*.docx"; sfd.Title = "Сохранить документацию как..."; sfd.FileName = "Generated_Documentation.docx"; if (sfd.ShowDialog() == DialogResult.OK) { return sfd.FileName; } return null; } } // Метод остается асинхронным (async Task) // Метод остается асинхронным (async Task) private async Task CreateDocxFromMarkdown(string markdown, string outputPath, string sampleDocxPath) { // --- Логирование Markdown --- Debug.WriteLine("--- ИСХОДНЫЙ MARKDOWN ---"); Debug.WriteLine(markdown ?? "[NULL]"); Debug.WriteLine("--- КОНЕЦ MARKDOWN ---"); // --------------------------- var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); string html = "[NULL]"; if (markdown != null) { html = Markdown.ToHtml(markdown, pipeline); } // --- Логирование HTML --- Debug.WriteLine("--- ПОЛУЧЕННЫЙ HTML ---"); Debug.WriteLine(html); Debug.WriteLine("--- КОНЕЦ HTML ---"); // ------------------------ try { // Шаг 1: Клонирование шаблона. Шаблон будет закрыт сразу после клонирования. using (WordprocessingDocument templateDoc = WordprocessingDocument.Open(sampleDocxPath, false)) { // Клонируем шаблон в ВЫХОДНОЙ файл и открываем для редактирования using (WordprocessingDocument wordDoc = (WordprocessingDocument)templateDoc.Clone(outputPath, true)) { // Шаг 2: Работа с клонированным документом (wordDoc) MainDocumentPart mainPart = wordDoc.MainDocumentPart; Body body = mainPart.Document.Body; // !!! ОЧИЩАЕМ ТЕЛО ДОКУМЕНТА !!! body.RemoveAllChildren(); body.RemoveAllChildren(); // Шаг 3: Используем HtmlConverter для преобразования HTML и добавления в пустое тело HtmlConverter converter = new HtmlConverter(mainPart); await converter.ParseHtml(html); // ParseHtml добавляет элементы в mainPart.Document.Body // Шаг 4: Плейсхолдер НЕ НУЖЕН и его поиск/удаление НЕ НУЖНЫ // Сохранение происходит автоматически при выходе из using (wordDoc) } // wordDoc закрывается и сохраняется здесь } // templateDoc закрывается здесь } catch (IOException ioEx) { string logInfo = $"\n\n--- Markdown Log ---\n{markdown ?? "[NULL]"}\n--- HTML Log ---\n{html}"; throw new IOException($"Ошибка доступа к файлу при клонировании/создании DOCX '{outputPath}': {ioEx.Message}. Убедитесь, что файл не открыт в другой программе.{logInfo}", ioEx); } catch (Exception ex) { // Убираем проверку на InvalidOperationException, так как плейсхолдера нет string logInfo = $"\n\n--- Markdown Log ---\n{markdown ?? "[NULL]"}\n--- HTML Log ---\n{html}"; throw new Exception($"Ошибка при создании DOCX файла из Markdown/HTML:\n{ex.Message}{logInfo}", ex); } } } }