APPdoc/Form1.cs

433 lines
24 KiB
C#
Raw Normal View History

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<string> 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<Paragraph>())
{
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<string> 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<Paragraph>();
body.RemoveAllChildren<Table>();
// Шаг 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);
}
}
}
}