Compare commits

6 Commits

Author SHA1 Message Date
b9736293d3 moving to openrouter
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 3m39s
Mindforge Cronjob Build and Deploy / Build Mindforge Cronjob Image (push) Successful in 3m49s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 38s
Mindforge Cronjob Build and Deploy / Deploy Mindforge Cronjob (internal) (push) Successful in 30s
2026-04-04 21:09:18 -03:00
d0543544f8 changing to use openrouter 2026-04-04 21:00:30 -03:00
a860bb8921 fixing deploy
All checks were successful
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 3m17s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 11s
2026-03-26 20:04:56 -03:00
e3748f7e96 fixing deploy
Some checks failed
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Failing after 49s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Has been skipped
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 2m33s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 9s
2026-03-26 19:54:44 -03:00
20a5dc4b95 Merge pull request 'adding gitea service' (#1) from claude/beautiful-joliot into main
Some checks failed
Mindforge API Build and Deploy / Build Mindforge API Image (push) Failing after 51s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Has been skipped
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 3m17s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 9s
Reviewed-on: #1
2026-03-26 22:49:10 +00:00
83b1cb397d adding gitea service 2026-03-26 19:36:25 -03:00
34 changed files with 773 additions and 664 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/.git
**/.github
**/.claude
**/.gitea
**/bin
**/obj
**/.cache
**/.vs
**/.vscode
node_modules
appsettings.Development.json

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.claude/ .claude/
_bmad*

View File

@@ -0,0 +1,11 @@
**/.git
**/.github
**/.claude
**/.gitea
**/bin
**/obj
**/.cache
**/.vs
**/.vscode
node_modules
appsettings.Development.json

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Mindforge.API.Services.Interfaces;
namespace Mindforge.API.Controllers
{
[ApiController]
[Route("api/v1/repository")]
public class RepositoryController : ControllerBase
{
private readonly IGiteaService _giteaService;
public RepositoryController(IGiteaService giteaService)
{
_giteaService = giteaService;
}
[HttpGet("info")]
public IActionResult GetInfo()
{
return Ok(new { name = _giteaService.GetRepositoryName() });
}
[HttpGet("tree")]
public async Task<IActionResult> GetTree()
{
var tree = await _giteaService.GetFileTreeAsync();
return Ok(tree);
}
[HttpGet("file")]
public async Task<IActionResult> GetFile([FromQuery] string path)
{
if (string.IsNullOrWhiteSpace(path))
return BadRequest(new { error = "path query parameter is required." });
var content = await _giteaService.GetFileContentAsync(path);
return Ok(new { path, content });
}
}
}

View File

@@ -1,8 +0,0 @@
namespace Mindforge.API.Models.Enums
{
public enum LlmProvider
{
OpenAI,
Gemini
}
}

View File

@@ -0,0 +1,10 @@
namespace Mindforge.API.Models
{
public class FileTreeNode
{
public string Name { get; set; } = "";
public string Path { get; set; } = "";
public string Type { get; set; } = ""; // "file" | "folder"
public List<FileTreeNode>? Children { get; set; }
}
}

View File

@@ -7,6 +7,9 @@ using Mindforge.API.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Ensure environment variables are loaded into IConfiguration
builder.Configuration.AddEnvironmentVariables();
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Logging.AddConsole(); builder.Logging.AddConsole();
@@ -31,12 +34,12 @@ builder.Services.AddHttpClient();
// Register Providers // Register Providers
builder.Services.AddScoped<ILlmApiProvider, OpenAIApiProvider>(); builder.Services.AddScoped<ILlmApiProvider, OpenAIApiProvider>();
builder.Services.AddScoped<ILlmApiProvider, GeminiApiProvider>();
// Register Services // Register Services
builder.Services.AddScoped<IAgentService, AgentService>(); builder.Services.AddScoped<IAgentService, AgentService>();
builder.Services.AddScoped<IFileService, FileService>(); builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IFlashcardService, FlashcardService>(); builder.Services.AddScoped<IFlashcardService, FlashcardService>();
builder.Services.AddScoped<IGiteaService, GiteaService>();
var app = builder.Build(); var app = builder.Build();
@@ -55,17 +58,26 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
// Check for env vars // Check for env vars
var openAiKey = builder.Configuration["OPENAI_API_KEY"]; var openAiApiUrl = builder.Configuration["OPENAI_API_URL"];
var geminiKey = builder.Configuration["GEMINI_API_KEY"]; var openAiToken = builder.Configuration["OPENAI_TOKEN"];
var openAiModel = builder.Configuration["OPENAI_MODEL"];
if (string.IsNullOrEmpty(openAiKey)) if (string.IsNullOrEmpty(openAiApiUrl))
{ app.Logger.LogWarning("OPENAI_API_URL not found in configuration.");
app.Logger.LogWarning("OPENAI_API_KEY not found in configuration.");
}
if (string.IsNullOrEmpty(geminiKey)) if (string.IsNullOrEmpty(openAiToken))
{ app.Logger.LogWarning("OPENAI_TOKEN not found in configuration.");
app.Logger.LogWarning("GEMINI_API_KEY not found in configuration.");
} if (string.IsNullOrEmpty(openAiModel))
app.Logger.LogWarning("OPENAI_MODEL not found in configuration.");
var giteaRepoUrl = builder.Configuration["GITEA_REPO_URL"];
var giteaAccessToken = builder.Configuration["GITEA_ACCESS_TOKEN"];
if (string.IsNullOrEmpty(giteaRepoUrl))
app.Logger.LogWarning("GITEA_REPO_URL not found in configuration. Repository features will not work.");
if (string.IsNullOrEmpty(giteaAccessToken))
app.Logger.LogWarning("GITEA_ACCESS_TOKEN not found in configuration. Repository features will not work.");
app.Run(); app.Run();

View File

@@ -1,202 +0,0 @@
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
namespace Mindforge.API.Providers
{
public class GeminiApiProvider : ILlmApiProvider
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<GeminiApiProvider> _logger;
public GeminiApiProvider(HttpClient httpClient, IConfiguration configuration, ILogger<GeminiApiProvider> logger)
{
_httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromMinutes(5);
_configuration = configuration;
_logger = logger;
}
public async Task<string> SendRequestAsync(string systemPrompt, string userPrompt, string model)
{
var apiKey = _configuration["GEMINI_API_KEY"];
if (string.IsNullOrEmpty(apiKey))
{
throw new Exception("GEMINI_API_KEY not found in configuration.");
}
var apiBase = "https://generativelanguage.googleapis.com/v1beta";
var url = $"{apiBase.TrimEnd('/')}/models/{model}:generateContent?key={apiKey}";
var reqBody = new
{
system_instruction = string.IsNullOrEmpty(systemPrompt) ? null : new
{
parts = new[] { new { text = systemPrompt } }
},
contents = new[]
{
new
{
role = "user",
parts = new[] { new { text = userPrompt } }
}
}
};
var jsonBody = JsonSerializer.Serialize(reqBody, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Gemini API error status {(int)response.StatusCode}: {responseBody}");
}
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
if (result.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
{
var content = candidates[0].GetProperty("content");
if (content.TryGetProperty("parts", out var parts) && parts.GetArrayLength() > 0)
{
return parts[0].GetProperty("text").GetString() ?? string.Empty;
}
}
throw new Exception("empty response from Gemini API");
}
public async Task<string> SendRequestBatchAsync(string systemPrompt, string userPrompt, string model)
{
var apiKey = _configuration["GEMINI_API_KEY"];
if (string.IsNullOrEmpty(apiKey))
throw new Exception("GEMINI_API_KEY not found in configuration.");
var apiBase = "https://generativelanguage.googleapis.com/v1beta";
var jsonOptions = new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull };
// Build single inline request
var batchBody = new
{
batch = new
{
display_name = "mindforge-batch",
input_config = new
{
requests = new
{
requests = new[]
{
new
{
request = new
{
system_instruction = string.IsNullOrEmpty(systemPrompt) ? null : new
{
parts = new[] { new { text = systemPrompt } }
},
contents = new[]
{
new
{
role = "user",
parts = new[] { new { text = userPrompt } }
}
}
},
metadata = new { key = "request-1" }
}
}
}
}
}
};
// Submit batch job
var createUrl = $"{apiBase}/models/{model}:batchGenerateContent?key={apiKey}";
using var createReq = new HttpRequestMessage(HttpMethod.Post, createUrl);
createReq.Content = new StringContent(JsonSerializer.Serialize(batchBody, jsonOptions), Encoding.UTF8, "application/json");
var createResp = await _httpClient.SendAsync(createReq);
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
throw new Exception($"Gemini Batch API error creating job {(int)createResp.StatusCode}: {createBody}");
_logger.LogInformation("Gemini Batch API job created");
var createResult = JsonSerializer.Deserialize<JsonElement>(createBody);
if (!createResult.TryGetProperty("name", out var nameEl))
throw new Exception("Gemini Batch API did not return a job name.");
var batchName = nameEl.GetString()!;
var pollUrl = $"{apiBase}/{batchName}?key={apiKey}";
_logger.LogInformation("Gemini Batch API job name: {BatchName}", batchName);
// Poll until terminal state
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(10));
using var pollReq = new HttpRequestMessage(HttpMethod.Get, pollUrl);
var pollResp = await _httpClient.SendAsync(pollReq);
var pollBody = await pollResp.Content.ReadAsStringAsync();
if (!pollResp.IsSuccessStatusCode)
throw new Exception($"Gemini Batch API error polling status {(int)pollResp.StatusCode}: {pollBody}");
var pollResult = JsonSerializer.Deserialize<JsonElement>(pollBody);
var metadata = pollResult.GetProperty("metadata");
var state = metadata.GetProperty("state");
_logger.LogInformation("Gemini Batch API job state: {State}", state.GetString());
switch (state.GetString())
{
case "BATCH_STATE_SUCCEEDED":
if (pollResult.TryGetProperty("response", out var batchResponse) &&
batchResponse.TryGetProperty("inlinedResponses", out var inlinedResponses) &&
inlinedResponses.TryGetProperty("inlinedResponses", out var inlinedResponsesInternal) &&
inlinedResponsesInternal.GetArrayLength() > 0)
{
_logger.LogInformation("Gemini Batch API job succeeded");
var first = inlinedResponsesInternal[0];
if (first.TryGetProperty("error", out var reqError))
throw new Exception($"Gemini Batch request error: {reqError}");
if (first.TryGetProperty("response", out var innerResponse) &&
innerResponse.TryGetProperty("candidates", out var candidates) &&
candidates.GetArrayLength() > 0)
{
var content = candidates[0].GetProperty("content");
if (content.TryGetProperty("parts", out var parts) && parts.GetArrayLength() > 0)
return parts[0].GetProperty("text").GetString() ?? string.Empty;
}
}
throw new Exception("Gemini Batch job succeeded but returned no content.");
case "BATCH_STATE_FAILED":
throw new Exception($"Gemini Batch job failed: {pollBody}");
case "BATCH_STATE_CANCELLED":
throw new Exception("Gemini Batch job was cancelled.");
case "BATCH_STATE_EXPIRED":
throw new Exception("Gemini Batch job expired before completing.");
// BATCH_STATE_PENDING / BATCH_STATE_RUNNING — keep polling
}
}
}
}
}

View File

@@ -4,7 +4,6 @@ namespace Mindforge.API.Providers
{ {
public interface ILlmApiProvider public interface ILlmApiProvider
{ {
Task<string> SendRequestAsync(string systemPrompt, string userPrompt, string model); Task<string> SendRequestAsync(string systemPrompt, string userPrompt);
Task<string> SendRequestBatchAsync(string systemPrompt, string userPrompt, string model);
} }
} }

View File

@@ -22,28 +22,29 @@ namespace Mindforge.API.Providers
_logger = logger; _logger = logger;
} }
public async Task<string> SendRequestAsync(string systemPrompt, string userPrompt, string model) public async Task<string> SendRequestAsync(string systemPrompt, string userPrompt)
{ {
var apiKey = _configuration["OPENAI_API_KEY"]; var apiUrl = _configuration["OPENAI_API_URL"];
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiUrl))
{ throw new Exception("OPENAI_API_URL not found in configuration.");
throw new Exception("OPENAI_API_KEY not found in configuration.");
}
var apiBase = "https://api.openai.com/v1"; var token = _configuration["OPENAI_TOKEN"];
var url = $"{apiBase.TrimEnd('/')}/responses"; if (string.IsNullOrEmpty(token))
throw new Exception("OPENAI_TOKEN not found in configuration.");
var model = _configuration["OPENAI_MODEL"];
if (string.IsNullOrEmpty(model))
throw new Exception("OPENAI_MODEL not found in configuration.");
var url = $"{apiUrl.TrimEnd('/')}/chat/completions";
var reqBody = new var reqBody = new
{ {
model = model, model = model,
input = new[] messages = new[]
{ {
new { role = "developer", content = systemPrompt }, new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt } new { role = "user", content = userPrompt }
},
reasoning = new
{
effort = "low"
} }
}; };
@@ -54,7 +55,7 @@ namespace Mindforge.API.Providers
for (int i = 0; i < 5; i++) for (int i = 0; i < 5; i++)
{ {
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
try try
@@ -64,47 +65,30 @@ namespace Mindforge.API.Providers
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
lastErr = new Exception($"OpenAI API error status {(int)response.StatusCode}: {responseBody}"); lastErr = new Exception($"API error status {(int)response.StatusCode}: {responseBody}");
await Task.Delay(TimeSpan.FromSeconds(1 << i)); await Task.Delay(TimeSpan.FromSeconds(1 << i));
continue; continue;
} }
var result = JsonSerializer.Deserialize<JsonElement>(responseBody); var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
if (result.TryGetProperty("output", out var outputArray)) if (result.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
{ {
foreach (var outputItem in outputArray.EnumerateArray()) var message = choices[0].GetProperty("message");
{ return message.GetProperty("content").GetString() ?? string.Empty;
if (outputItem.TryGetProperty("content", out var contentArray))
{
foreach (var contentItem in contentArray.EnumerateArray())
{
if (contentItem.TryGetProperty("text", out var textContent))
{
return textContent.GetString() ?? string.Empty;
}
}
}
}
} }
_logger.LogWarning("OpenAI API raw response: {responseBody}", responseBody); _logger.LogWarning("API raw response: {responseBody}", responseBody);
throw new Exception("Empty response from API.");
throw new Exception("empty response from OpenAI API");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error in OpenAI API request"); _logger.LogError(ex, "Error in API request");
lastErr = ex; lastErr = ex;
await Task.Delay(TimeSpan.FromSeconds(1 << i)); await Task.Delay(TimeSpan.FromSeconds(1 << i));
} }
} }
throw new Exception($"failed to get OpenAI response after 5 attempts. Last error: {lastErr?.Message}", lastErr); throw new Exception($"Failed to get response after 5 attempts. Last error: {lastErr?.Message}", lastErr);
}
public async Task<string> SendRequestBatchAsync(string systemPrompt, string userPrompt, string model)
{
throw new NotImplementedException();
} }
} }
} }

View File

@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Mindforge.API.Models.Enums;
using Mindforge.API.Providers; using Mindforge.API.Providers;
using Mindforge.API.Services.Interfaces; using Mindforge.API.Services.Interfaces;
@@ -10,39 +6,16 @@ namespace Mindforge.API.Services
{ {
public class AgentService : IAgentService public class AgentService : IAgentService
{ {
private readonly IEnumerable<ILlmApiProvider> _providers; private readonly ILlmApiProvider _provider;
public AgentService(IEnumerable<ILlmApiProvider> providers) public AgentService(ILlmApiProvider provider)
{ {
_providers = providers; _provider = provider;
} }
public Task<string> ProcessRequestAsync(LlmProvider providerEnum, string systemPrompt, string userPrompt, string model) public Task<string> ProcessRequestAsync(string systemPrompt, string userPrompt)
{ {
ILlmApiProvider provider = providerEnum switch return _provider.SendRequestAsync(systemPrompt, userPrompt);
{
LlmProvider.OpenAI => _providers.OfType<OpenAIApiProvider>().FirstOrDefault()
?? throw new Exception("OpenAI provider not registered"),
LlmProvider.Gemini => _providers.OfType<GeminiApiProvider>().FirstOrDefault()
?? throw new Exception("Gemini provider not registered"),
_ => throw new Exception("Unknown provider")
};
return provider.SendRequestAsync(systemPrompt, userPrompt, model);
}
public Task<string> ProcessRequestBatchAsync(LlmProvider providerEnum, string systemPrompt, string userPrompt, string model)
{
ILlmApiProvider provider = providerEnum switch
{
LlmProvider.OpenAI => _providers.OfType<OpenAIApiProvider>().FirstOrDefault()
?? throw new Exception("OpenAI provider not registered"),
LlmProvider.Gemini => _providers.OfType<GeminiApiProvider>().FirstOrDefault()
?? throw new Exception("Gemini provider not registered"),
_ => throw new Exception("Unknown provider")
};
return provider.SendRequestBatchAsync(systemPrompt, userPrompt, model);
} }
} }
} }

View File

@@ -1,6 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Mindforge.API.Models.Enums;
using Mindforge.API.Models.Requests; using Mindforge.API.Models.Requests;
using Mindforge.API.Services.Interfaces; using Mindforge.API.Services.Interfaces;
using Mindforge.API.Exceptions; using Mindforge.API.Exceptions;
@@ -11,9 +9,6 @@ namespace Mindforge.API.Services
{ {
private readonly IAgentService _agentService; private readonly IAgentService _agentService;
private const LlmProvider DefaultProvider = LlmProvider.OpenAI;
private const string DefaultModel = "gpt-5-mini";
public FileService(IAgentService agentService) public FileService(IAgentService agentService)
{ {
_agentService = agentService; _agentService = agentService;
@@ -55,7 +50,7 @@ Os resumos serão utilizados para concursos públicos, principalmente para a ban
string userPrompt = $"Arquivo: {request.FileName}\nConteúdo:\n{request.FileContent}"; string userPrompt = $"Arquivo: {request.FileName}\nConteúdo:\n{request.FileContent}";
return await _agentService.ProcessRequestAsync(DefaultProvider, systemPrompt, userPrompt, DefaultModel); return await _agentService.ProcessRequestAsync(systemPrompt, userPrompt);
} }
} }
} }

View File

@@ -1,5 +1,4 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Mindforge.API.Models.Enums;
using Mindforge.API.Models.Requests; using Mindforge.API.Models.Requests;
using Mindforge.API.Services.Interfaces; using Mindforge.API.Services.Interfaces;
@@ -10,9 +9,6 @@ namespace Mindforge.API.Services
private readonly IAgentService _agentService; private readonly IAgentService _agentService;
private readonly ILogger<FlashcardService> _logger; private readonly ILogger<FlashcardService> _logger;
private const LlmProvider DefaultProvider = LlmProvider.Gemini;
private string DefaultModel = "gemini-3.1-flash-image-preview";
public FlashcardService(IAgentService agentService, ILogger<FlashcardService> logger) public FlashcardService(IAgentService agentService, ILogger<FlashcardService> logger)
{ {
_agentService = agentService; _agentService = agentService;
@@ -21,25 +17,12 @@ namespace Mindforge.API.Services
public async Task<string> GenerateFlashcardsAsync(FlashcardGenerateRequest request) public async Task<string> GenerateFlashcardsAsync(FlashcardGenerateRequest request)
{ {
var extraPrompt = ""; var extraPrompt = request.Mode switch
switch (request.Mode)
{ {
case FlashcardMode.Basic: FlashcardMode.Detailed => "Crie flashcards mais detalhados.",
DefaultModel = "gemini-3.1-flash-lite-preview"; FlashcardMode.Hyper => "Adicione também pequenas questões para fixação, para que o usuário possa testar seus conhecimentos. As questões devem ser curtas e objetivas, como se fosse cobradas em prova mesmo.",
break; _ => ""
case FlashcardMode.Simple: };
DefaultModel = "gemini-3.1-flash-image-preview";
break;
case FlashcardMode.Detailed:
DefaultModel = "gemini-3.1-flash-image-preview";
extraPrompt = "Crie flashcards mais detalhados.";
break;
case FlashcardMode.Hyper:
DefaultModel = "gemini-3.1-pro-preview";
extraPrompt = "Adicione também pequenas questões para fixação, para que o usuário possa testar seus conhecimentos. As questões devem ser curtas e objetivas, como se fosse cobradas em prova mesmo.";
break;
}
string systemPrompt = $@"Você é um assistente educacional especializado em criar flashcards para o Anki. string systemPrompt = $@"Você é um assistente educacional especializado em criar flashcards para o Anki.
Baseado no texto fornecido, crie exatamente {request.Amount} flashcards que focam nos conceitos mais importantes e difíceis. Baseado no texto fornecido, crie exatamente {request.Amount} flashcards que focam nos conceitos mais importantes e difíceis.
@@ -57,8 +40,7 @@ Com base no arquivo fornecido, crie exatamente {request.Amount} flashcards que f
string userPrompt = $"Arquivo: {request.FileName}\nConteúdo:\n{request.FileContent}"; string userPrompt = $"Arquivo: {request.FileName}\nConteúdo:\n{request.FileContent}";
//var result = await _agentService.ProcessRequestAsync(DefaultProvider, systemPrompt, userPrompt, DefaultModel); var result = await _agentService.ProcessRequestAsync(systemPrompt, userPrompt);
var result = await _agentService.ProcessRequestBatchAsync(DefaultProvider, systemPrompt, userPrompt, DefaultModel);
var lines = result.Split('\n'); var lines = result.Split('\n');

View File

@@ -0,0 +1,165 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Mindforge.API.Exceptions;
using Mindforge.API.Models;
using Mindforge.API.Services.Interfaces;
namespace Mindforge.API.Services
{
public class GiteaService : IGiteaService
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string _owner;
private readonly string _repo;
private readonly string _token;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public GiteaService(HttpClient httpClient, IConfiguration configuration)
{
_httpClient = httpClient;
var repoUrl = configuration["GITEA_REPO_URL"];
if (string.IsNullOrEmpty(repoUrl))
throw new InvalidOperationException("GITEA_REPO_URL is not set in configuration.");
_token = configuration["GITEA_ACCESS_TOKEN"]
?? throw new InvalidOperationException("GITEA_ACCESS_TOKEN is not set in configuration.");
// Parse: https://host/owner/repo or https://host/owner/repo.git
var normalizedUrl = repoUrl.TrimEnd('/').TrimEnd(".git".ToCharArray());
var uri = new Uri(normalizedUrl);
_baseUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}";
var parts = uri.AbsolutePath.Trim('/').Split('/');
_owner = parts[0];
_repo = parts[1];
}
public string GetRepositoryName() => _repo;
public async Task<List<FileTreeNode>> GetFileTreeAsync()
{
// Get the master branch to obtain the latest commit SHA
var branchJson = await GetApiAsync($"/api/v1/repos/{_owner}/{_repo}/branches/master");
var branch = JsonSerializer.Deserialize<GiteaBranch>(branchJson, JsonOptions)
?? throw new InvalidOperationException("Failed to parse branch response from Gitea.");
var treeSha = branch.Commit.Id;
// Fetch the full recursive tree
var treeJson = await GetApiAsync($"/api/v1/repos/{_owner}/{_repo}/git/trees/{treeSha}?recursive=true");
var treeResponse = JsonSerializer.Deserialize<GiteaTreeResponse>(treeJson, JsonOptions)
?? throw new InvalidOperationException("Failed to parse tree response from Gitea.");
return BuildTree(treeResponse.Tree);
}
public async Task<string> GetFileContentAsync(string path)
{
var request = new HttpRequestMessage(HttpMethod.Get,
$"{_baseUrl}/api/v1/repos/{_owner}/{_repo}/raw/{path}?ref=master");
request.Headers.Add("Authorization", $"token {_token}");
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new UserException($"File not found in repository: {path}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private async Task<string> GetApiAsync(string endpoint)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}{endpoint}");
request.Headers.Add("Authorization", $"token {_token}");
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private static List<FileTreeNode> BuildTree(List<GiteaTreeItem> items)
{
var root = new List<FileTreeNode>();
var folderMap = new Dictionary<string, FileTreeNode>();
// Only include .md files and their parent folders
var mdFiles = items.Where(i => i.Type == "blob" && i.Path.EndsWith(".md")).ToList();
var neededFolders = new HashSet<string>();
foreach (var file in mdFiles)
{
var dir = System.IO.Path.GetDirectoryName(file.Path)?.Replace('\\', '/');
while (!string.IsNullOrEmpty(dir))
{
neededFolders.Add(dir);
dir = System.IO.Path.GetDirectoryName(dir)?.Replace('\\', '/');
}
}
// Create folder nodes
foreach (var item in items.Where(i => i.Type == "tree" && neededFolders.Contains(i.Path)).OrderBy(i => i.Path))
{
var node = new FileTreeNode
{
Name = System.IO.Path.GetFileName(item.Path),
Path = item.Path,
Type = "folder",
Children = []
};
folderMap[item.Path] = node;
}
// Place file and folder nodes into parent
var allNodes = mdFiles.Select(i => new FileTreeNode
{
Name = System.IO.Path.GetFileName(i.Path),
Path = i.Path,
Type = "file"
}).Concat(folderMap.Values).OrderBy(n => n.Path);
foreach (var node in allNodes)
{
var parentPath = System.IO.Path.GetDirectoryName(node.Path)?.Replace('\\', '/');
if (string.IsNullOrEmpty(parentPath))
root.Add(node);
else if (folderMap.TryGetValue(parentPath, out var parent))
parent.Children!.Add(node);
}
return root;
}
// ---- Gitea API DTOs ----
private class GiteaBranch
{
[JsonPropertyName("commit")]
public GiteaCommit Commit { get; set; } = new();
}
private class GiteaCommit
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
}
private class GiteaTreeResponse
{
[JsonPropertyName("tree")]
public List<GiteaTreeItem> Tree { get; set; } = [];
}
private class GiteaTreeItem
{
[JsonPropertyName("path")]
public string Path { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = ""; // "blob" | "tree"
}
}
}

View File

@@ -1,11 +1,9 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Mindforge.API.Models.Enums;
namespace Mindforge.API.Services.Interfaces namespace Mindforge.API.Services.Interfaces
{ {
public interface IAgentService public interface IAgentService
{ {
Task<string> ProcessRequestAsync(LlmProvider provider, string systemPrompt, string userPrompt, string model); Task<string> ProcessRequestAsync(string systemPrompt, string userPrompt);
Task<string> ProcessRequestBatchAsync(LlmProvider provider, string systemPrompt, string userPrompt, string model);
} }
} }

View File

@@ -0,0 +1,11 @@
using Mindforge.API.Models;
namespace Mindforge.API.Services.Interfaces
{
public interface IGiteaService
{
Task<List<FileTreeNode>> GetFileTreeAsync();
Task<string> GetFileContentAsync(string path);
string GetRepositoryName();
}
}

View File

@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"OPENAI_API_URL": "https://openrouter.ai/api/v1",
"OPENAI_TOKEN": "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",
"OPENAI_MODEL": "openai/gpt-5.4-mini",
"GITEA_REPO_URL": "",
"GITEA_ACCESS_TOKEN": ""
} }

View File

@@ -20,16 +20,25 @@ spec:
ports: ports:
- containerPort: 8080 - containerPort: 8080
env: env:
- name: OPENAI_API_KEY - name: OPENAI_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: mindforge-secrets name: mindforge-secrets
key: OPENAI_API_KEY key: OPENAI_TOKEN
- name: GEMINI_API_KEY - name: OPENAI_API_URL
value: https://openrouter.ai/api/v1
- name: OPENAI_MODEL
value: openai/gpt-5.4-mini
- name: GITEA_REPO_URL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: mindforge-secrets name: mindforge-secrets
key: GEMINI_API_KEY key: GITEA_REPO_URL
- name: GITEA_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: mindforge-secrets
key: GITEA_ACCESS_TOKEN
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"

View File

@@ -0,0 +1,11 @@
**/.git
**/.github
**/.claude
**/.gitea
**/bin
**/obj
**/.cache
**/.vs
**/.vscode
node_modules
appsettings.Development.json

View File

@@ -0,0 +1,89 @@
.file-tree {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem;
max-height: 380px;
overflow-y: auto;
font-size: 0.9rem;
}
.tree-folder {
margin-bottom: 2px;
}
.tree-folder-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
user-select: none;
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
}
.tree-folder-header:hover {
background: rgba(255, 255, 255, 0.07);
}
.tree-folder-arrow {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
width: 12px;
}
.tree-folder-name {
color: rgba(255, 255, 255, 0.85);
}
.tree-folder-children {
padding-left: 18px;
border-left: 1px solid rgba(255, 255, 255, 0.08);
margin-left: 6px;
}
.tree-file {
margin-bottom: 1px;
}
.tree-file-label {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
}
.tree-file-label:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
}
.tree-file-label input[type="checkbox"] {
accent-color: #7c6fcd;
width: 14px;
height: 14px;
cursor: pointer;
flex-shrink: 0;
}
.tree-file-name {
font-size: 0.875rem;
}
.tree-loading,
.tree-error,
.tree-empty {
padding: 1rem;
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
text-align: center;
}
.tree-error {
color: #ff7b72;
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'preact/hooks';
import { MindforgeApiService, type FileTreeNode } from '../services/MindforgeApiService';
import './FileTreeComponent.css';
interface FileTreeComponentProps {
selectedPaths: string[];
onSelectionChange: (paths: string[]) => void;
}
interface FolderNodeProps {
node: FileTreeNode;
selectedPaths: string[];
onToggle: (path: string) => void;
}
function FolderNode({ node, selectedPaths, onToggle }: FolderNodeProps) {
const [expanded, setExpanded] = useState(true);
if (node.type === 'file') {
return (
<div className="tree-file">
<label className="tree-file-label">
<input
type="checkbox"
checked={selectedPaths.includes(node.path)}
onChange={() => onToggle(node.path)}
/>
<span className="tree-file-name">{node.name}</span>
</label>
</div>
);
}
return (
<div className="tree-folder">
<div className="tree-folder-header" onClick={() => setExpanded(e => !e)}>
<span className="tree-folder-arrow">{expanded ? '▾' : '▸'}</span>
<span className="tree-folder-name">{node.name}</span>
</div>
{expanded && node.children && (
<div className="tree-folder-children">
{node.children.map(child => (
<FolderNode key={child.path} node={child} selectedPaths={selectedPaths} onToggle={onToggle} />
))}
</div>
)}
</div>
);
}
export function FileTreeComponent({ selectedPaths, onSelectionChange }: FileTreeComponentProps) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
MindforgeApiService.getRepositoryTree()
.then(setTree)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
const togglePath = (path: string) => {
if (selectedPaths.includes(path)) {
onSelectionChange(selectedPaths.filter(p => p !== path));
} else {
onSelectionChange([...selectedPaths, path]);
}
};
if (loading) return <div className="tree-loading">Carregando repositório...</div>;
if (error) return <div className="tree-error">Erro ao carregar repositório: {error}</div>;
if (tree.length === 0) return <div className="tree-empty">Nenhum arquivo encontrado no repositório.</div>;
return (
<div className="file-tree">
{tree.map(node => (
<FolderNode key={node.path} node={node} selectedPaths={selectedPaths} onToggle={togglePath} />
))}
</div>
);
}

View File

@@ -1,5 +1,8 @@
import { useState, useRef } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { MindforgeApiService, type FlashcardMode } from '../services/MindforgeApiService'; import { MindforgeApiService, type FlashcardMode } from '../services/MindforgeApiService';
import { FileTreeComponent } from './FileTreeComponent';
import { Button } from './Button';
import './FlashcardComponent.css';
// Mapping of flashcard mode to its maximum allowed amount // Mapping of flashcard mode to its maximum allowed amount
const modeMax: Record<FlashcardMode, number> = { const modeMax: Record<FlashcardMode, number> = {
@@ -9,9 +12,6 @@ const modeMax: Record<FlashcardMode, number> = {
Hyper: 130, Hyper: 130,
}; };
import { Button } from './Button';
import './FlashcardComponent.css';
function utf8ToBase64(str: string): string { function utf8ToBase64(str: string): string {
const bytes = new TextEncoder().encode(str); const bytes = new TextEncoder().encode(str);
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
@@ -19,8 +19,7 @@ function utf8ToBase64(str: string): string {
} }
export function FlashcardComponent() { export function FlashcardComponent() {
const [text, setText] = useState(''); const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [fileName, setFileName] = useState('manual_input.md');
const [amount, setAmount] = useState<number>(20); const [amount, setAmount] = useState<number>(20);
const [mode, setMode] = useState<FlashcardMode>('Simple'); const [mode, setMode] = useState<FlashcardMode>('Simple');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -28,30 +27,13 @@ export function FlashcardComponent() {
const [success, setSuccess] = useState<boolean>(false); const [success, setSuccess] = useState<boolean>(false);
const handleModeChange = (newMode: FlashcardMode) => { const handleModeChange = (newMode: FlashcardMode) => {
setMode(newMode); // set the mode setMode(newMode);
setAmount(20); // set the default amount setAmount(20);
};
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
setFileName(file.name);
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setText(event.target.result as string);
}
};
reader.readAsText(file);
}
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
if (!text.trim()) { if (selectedPaths.length === 0) {
setError('Por favor, insira algum texto ou faça upload de um arquivo para gerar os flashcards.'); setError('Selecione pelo menos um arquivo do repositório para gerar os flashcards.');
return; return;
} }
@@ -60,16 +42,25 @@ export function FlashcardComponent() {
setSuccess(false); setSuccess(false);
try { try {
const base64Content = utf8ToBase64(text); // Fetch all selected files and merge their content
const fileContents = await Promise.all(
selectedPaths.map(path => MindforgeApiService.getFileContent(path))
);
const mergedContent = fileContents.map(f => f.content).join('\n\n---\n\n');
const mergedFileName = selectedPaths.length === 1
? (selectedPaths[0].split('/').pop() ?? 'merged.md')
: 'merged.md';
const base64Content = utf8ToBase64(mergedContent);
const res = await MindforgeApiService.generateFlashcards({ const res = await MindforgeApiService.generateFlashcards({
fileContent: base64Content, fileContent: base64Content,
fileName, fileName: mergedFileName,
amount, amount,
mode mode,
}); });
const csvContent = res.result; downloadCSV(res.result);
downloadCSV(csvContent);
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Ocorreu um erro ao gerar os flashcards.'); setError(err.message || 'Ocorreu um erro ao gerar os flashcards.');
@@ -79,7 +70,6 @@ export function FlashcardComponent() {
}; };
const downloadCSV = (content: string) => { const downloadCSV = (content: string) => {
// Adicionar BOM do UTF-8 para o Excel reconhecer os caracteres corretamente
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
const blob = new Blob([bom, content], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([bom, content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -95,38 +85,22 @@ export function FlashcardComponent() {
return ( return (
<div className="flashcard-container"> <div className="flashcard-container">
<h2 className="title" style={{ fontSize: '2.5rem' }}>Gerador de Flashcards</h2> <h2 className="title" style={{ fontSize: '2.5rem' }}>Gerador de Flashcards</h2>
<p className="subtitle">Crie flashcards baseados nos seus materiais de estudo rapidamente.</p> <p className="subtitle">Selecione os arquivos do repositório para gerar flashcards. Múltiplos arquivos serão combinados.</p>
<div className="flashcard-form"> <div className="flashcard-form">
<div className="input-group"> <div className="input-group">
<label>Texto (Markdown)</label> <label>Arquivos do Repositório</label>
<textarea <FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
className="text-area" {selectedPaths.length > 0 && (
value={text} <div style={{ fontSize: '0.85rem', color: 'rgba(255,255,255,0.5)', marginTop: '0.4rem' }}>
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)} {selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado{selectedPaths.length !== 1 ? 's' : ''}
placeholder="Cole seu texto de estudo aqui ou faça upload do material..." {selectedPaths.length > 1 ? ' — conteúdo será combinado' : ''}
/>
</div> </div>
)}
<div className="file-input-wrapper">
<input
type="file"
accept=".md,.txt,.html"
ref={fileInputRef}
onChange={handleFileUpload}
className="file-input"
id="flashcard-file"
/>
<label htmlFor="flashcard-file" className="file-input-label">
📁 Escolher Arquivo
</label>
<span style={{ fontSize: '0.9rem', color: 'rgba(255,255,255,0.6)' }}>
{fileName !== 'manual_input.md' ? fileName : 'Nenhum arquivo selecionado'}
</span>
</div> </div>
<div className="input-group"> <div className="input-group">
<label>Quantidade Estimada de Flashcards (10 - 100)</label> <label>Quantidade Estimada de Flashcards (10 - {modeMax[mode]})</label>
<div className="slider-wrapper"> <div className="slider-wrapper">
<input <input
type="range" type="range"

View File

@@ -22,6 +22,31 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
position: relative;
}
.header-repo {
position: absolute;
right: 24px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 4px 10px;
}
.header-repo-icon {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.5);
}
.header-repo-name {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.65);
font-family: var(--font-main);
letter-spacing: 0.5px;
} }
.header-title { .header-title {

View File

@@ -1,3 +1,5 @@
import { useState, useEffect } from 'preact/hooks';
import { MindforgeApiService } from '../services/MindforgeApiService';
import './Header.css'; import './Header.css';
interface HeaderProps { interface HeaderProps {
@@ -5,14 +7,27 @@ interface HeaderProps {
} }
export function Header({ onGoHome }: HeaderProps) { export function Header({ onGoHome }: HeaderProps) {
const [repoName, setRepoName] = useState<string | null>(null);
useEffect(() => {
MindforgeApiService.getRepositoryInfo()
.then(info => setRepoName(info.name))
.catch(() => setRepoName(null));
}, []);
return ( return (
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} onClick={onGoHome}> <div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} onClick={onGoHome}>
<img src="/assets/mindforge.png" alt="Mindforge" width="55" height="55" style={{ marginRight: '10px' }} /> <img src="/assets/mindforge.png" alt="Mindforge" width="55" height="55" style={{ marginRight: '10px' }} />
<h1 class="header-title">Mindforge</h1> <h1 class="header-title">Mindforge</h1>
</div> </div>
{repoName && (
<div class="header-repo">
<span class="header-repo-icon"></span>
<span class="header-repo-name">{repoName}</span>
</div>
)}
</div> </div>
</header> </header>
); );

View File

@@ -1,5 +1,5 @@
.sidebar { .sidebar {
width: 280px; width: 240px;
background-color: var(--color-sidebar); background-color: var(--color-sidebar);
border-right: 1px solid rgba(255, 255, 255, 0.05); border-right: 1px solid rgba(255, 255, 255, 0.05);
display: flex; display: flex;

View File

@@ -8,6 +8,24 @@
gap: 1.5rem; gap: 1.5rem;
} }
.file-result-block {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
}
.file-result-title {
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
font-family: monospace;
}
.verificador-form { .verificador-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,6 @@
import { useState, useRef } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { MindforgeApiService } from '../services/MindforgeApiService'; import { MindforgeApiService } from '../services/MindforgeApiService';
import { FileTreeComponent } from './FileTreeComponent';
import { Button } from './Button'; import { Button } from './Button';
import * as diff from 'diff'; import * as diff from 'diff';
import { marked } from 'marked'; import { marked } from 'marked';
@@ -13,62 +14,66 @@ function utf8ToBase64(str: string): string {
type CheckTypeEnum = 'language' | 'content' | 'both'; type CheckTypeEnum = 'language' | 'content' | 'both';
export function VerificadorComponent() { interface FileResult {
const [text, setText] = useState(''); path: string;
const [fileName, setFileName] = useState('manual_input.md'); fileName: string;
const [checkType, setCheckType] = useState<CheckTypeEnum>('language'); originalContent: string;
languageResult: string | null;
contentResult: string | null;
error: string | null;
}
export function VerificadorComponent() {
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [checkType, setCheckType] = useState<CheckTypeEnum>('language');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FileResult[]>([]);
const [languageResult, setLanguageResult] = useState<string | null>(null);
const [contentResult, setContentResult] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
setFileName(file.name);
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setText(event.target.result as string);
}
};
reader.readAsText(file);
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!text.trim()) { if (selectedPaths.length === 0) {
setError('Por favor, insira algum texto ou faça upload de um arquivo.'); setError('Selecione pelo menos um arquivo do repositório.');
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
setLanguageResult(null); setResults([]);
setContentResult(null);
const base64Content = utf8ToBase64(text);
try { try {
const fileResults = await Promise.all(
selectedPaths.map(async (path): Promise<FileResult> => {
const fileName = path.split('/').pop() ?? path;
try {
const { content } = await MindforgeApiService.getFileContent(path);
const base64Content = utf8ToBase64(content);
let languageResult: string | null = null;
let contentResult: string | null = null;
if (checkType === 'both') { if (checkType === 'both') {
const [langRes, contRes] = await Promise.all([ const [langRes, contRes] = await Promise.all([
MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType: 'language' }), MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType: 'language' }),
MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType: 'content' }) MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType: 'content' }),
]); ]);
setLanguageResult(langRes.result); languageResult = langRes.result;
setContentResult(contRes.result); contentResult = contRes.result;
} else { } else {
const res = await MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType }); const res = await MindforgeApiService.checkFile({ fileContent: base64Content, fileName, checkType });
if (checkType === 'language') setLanguageResult(res.result); if (checkType === 'language') languageResult = res.result;
else setContentResult(res.result); else contentResult = res.result;
} }
return { path, fileName, originalContent: content, languageResult, contentResult, error: null };
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Ocorreu um erro ao processar sua requisição.'); return { path, fileName, originalContent: '', languageResult: null, contentResult: null, error: err.message };
}
})
);
setResults(fileResults);
} catch (err: any) {
setError(err.message || 'Ocorreu um erro ao processar os arquivos.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -93,34 +98,17 @@ export function VerificadorComponent() {
return ( return (
<div className="verificador-container"> <div className="verificador-container">
<h2 className="title" style={{ fontSize: '2.5rem' }}>Verificador de Arquivos</h2> <h2 className="title" style={{ fontSize: '2.5rem' }}>Verificador de Arquivos</h2>
<p className="subtitle">Faça o upload do seu arquivo Markdown para validação de linguagem ou conteúdo.</p> <p className="subtitle">Selecione os arquivos do repositório para validação de linguagem ou conteúdo.</p>
<div className="verificador-form"> <div className="verificador-form">
<div className="input-group"> <div className="input-group">
<label>Texto (Markdown)</label> <label>Arquivos do Repositório</label>
<textarea <FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
className="text-area" {selectedPaths.length > 0 && (
value={text} <div style={{ fontSize: '0.85rem', color: 'rgba(255,255,255,0.5)', marginTop: '0.4rem' }}>
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)} {selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado{selectedPaths.length !== 1 ? 's' : ''}
placeholder="Cole seu texto aqui ou faça upload de um arquivo..."
/>
</div> </div>
)}
<div className="file-input-wrapper">
<input
type="file"
accept=".md,.txt"
ref={fileInputRef}
onChange={handleFileUpload}
className="file-input"
id="verificador-file"
/>
<label htmlFor="verificador-file" className="file-input-label">
📁 Escolher Arquivo
</label>
<span style={{ fontSize: '0.9rem', color: 'rgba(255,255,255,0.6)' }}>
{fileName !== 'manual_input.md' ? fileName : 'Nenhum arquivo selecionado'}
</span>
</div> </div>
<div className="input-group"> <div className="input-group">
@@ -150,47 +138,56 @@ export function VerificadorComponent() {
</div> </div>
)} )}
{/* Render Results */} {!loading && results.length > 0 && (
{!loading && (languageResult || contentResult) && (
<div className="response-section"> <div className="response-section">
{checkType === 'language' && languageResult && ( {results.map((fileResult) => (
<div key={fileResult.path} className="file-result-block">
<div className="file-result-title">{fileResult.fileName}</div>
{fileResult.error && (
<div style={{ color: '#ff7b72', padding: '0.5rem' }}>{fileResult.error}</div>
)}
{!fileResult.error && checkType === 'language' && fileResult.languageResult && (
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Resultado - Linguagem (Diff)</div> <div className="pane-title">Linguagem (Diff)</div>
<div className="response-content"> <div className="response-content">
{renderDiff(text, languageResult)} {renderDiff(fileResult.originalContent, fileResult.languageResult)}
</div> </div>
</div> </div>
)} )}
{checkType === 'content' && contentResult && ( {!fileResult.error && checkType === 'content' && fileResult.contentResult && (
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Resultado - Conteúdo</div> <div className="pane-title">Conteúdo</div>
<div <div
className="response-content markdown-body" className="response-content markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(contentResult) as string }} dangerouslySetInnerHTML={{ __html: marked.parse(fileResult.contentResult) as string }}
/> />
</div> </div>
)} )}
{checkType === 'both' && languageResult && contentResult && ( {!fileResult.error && checkType === 'both' && fileResult.languageResult && fileResult.contentResult && (
<div className="side-by-side"> <div className="side-by-side">
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Linguagem (Diff)</div> <div className="pane-title">Linguagem (Diff)</div>
<div className="response-content" style={{ minHeight: '300px' }}> <div className="response-content" style={{ minHeight: '200px' }}>
{renderDiff(text, languageResult)} {renderDiff(fileResult.originalContent, fileResult.languageResult)}
</div> </div>
</div> </div>
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Conteúdo</div> <div className="pane-title">Conteúdo</div>
<div <div
className="response-content markdown-body" className="response-content markdown-body"
style={{ minHeight: '300px' }} style={{ minHeight: '200px' }}
dangerouslySetInnerHTML={{ __html: marked.parse(contentResult) as string }} dangerouslySetInnerHTML={{ __html: marked.parse(fileResult.contentResult) as string }}
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
))}
</div>
)} )}
</div> </div>
); );

View File

@@ -1,5 +1,21 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5123'; const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5123';
export interface FileTreeNode {
name: string;
path: string;
type: 'file' | 'folder';
children?: FileTreeNode[];
}
export interface RepositoryInfo {
name: string;
}
export interface FileContentResponse {
path: string;
content: string;
}
export interface CheckFileRequest { export interface CheckFileRequest {
fileContent: string; fileContent: string;
fileName: string; fileName: string;
@@ -52,5 +68,23 @@ export const MindforgeApiService = {
throw new Error(`Error generating flashcards: ${response.statusText}`); throw new Error(`Error generating flashcards: ${response.statusText}`);
} }
return response.json(); return response.json();
} },
async getRepositoryInfo(): Promise<RepositoryInfo> {
const response = await fetch(`${BASE_URL}/api/v1/repository/info`);
if (!response.ok) throw new Error(`Error fetching repository info: ${response.statusText}`);
return response.json();
},
async getRepositoryTree(): Promise<FileTreeNode[]> {
const response = await fetch(`${BASE_URL}/api/v1/repository/tree`);
if (!response.ok) throw new Error(`Error fetching repository tree: ${response.statusText}`);
return response.json();
},
async getFileContent(path: string): Promise<FileContentResponse> {
const response = await fetch(`${BASE_URL}/api/v1/repository/file?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error(`Error fetching file ${path}: ${response.statusText}`);
return response.json();
},
}; };

View File

@@ -1,14 +1,9 @@
GIT_REPOSITORY=https://git.url/user/repo.git GIT_REPOSITORY=https://git.url/user/repo.git
OPENAI_API_KEY=openai_api_key
GEMINI_API_KEY=gemini_api_key
DISCORD_WEBHOOK_URL=discord_webhook_channel_url DISCORD_WEBHOOK_URL=discord_webhook_channel_url
# LLM provider per agent function ("openai" or "gemini", defaults to "openai") # OpenAI-compatible provider (e.g. OpenRouter)
SUMMARY_CREATOR_PROVIDER=gemini OPENAI_API_URL=https://openrouter.ai/api/v1
SUMMARY_FORMATTER_PROVIDER=openai OPENAI_TOKEN=your_token_here
OPENAI_MODEL=openai/gpt-5.4-mini
# LLM models
GEMINI_MODEL=gemini-3-flash-preview
OPENAI_MODEL=gpt-5-mini
TOP_N_FILES=10 TOP_N_FILES=10

View File

@@ -22,16 +22,15 @@ spec:
secretKeyRef: secretKeyRef:
name: mindforge-secrets name: mindforge-secrets
key: GIT_REPOSITORY key: GIT_REPOSITORY
- name: GEMINI_API_KEY - name: OPENAI_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: mindforge-secrets name: mindforge-secrets
key: GEMINI_API_KEY key: OPENAI_TOKEN
- name: OPENAI_API_KEY - name: OPENAI_API_URL
valueFrom: value: https://openrouter.ai/api/v1
secretKeyRef: - name: OPENAI_MODEL
name: mindforge-secrets value: openai/gpt-5.4-mini
key: OPENAI_API_KEY
- name: DISCORD_WEBHOOK_URL - name: DISCORD_WEBHOOK_URL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -42,14 +41,6 @@ spec:
secretKeyRef: secretKeyRef:
name: mindforge-secrets name: mindforge-secrets
key: HAVEN_NOTIFY_URL key: HAVEN_NOTIFY_URL
- name: SUMMARY_CREATOR_PROVIDER
value: gemini
- name: SUMMARY_FORMATTER_PROVIDER
value: openai
- name: GEMINI_MODEL
value: gemini-3-flash-preview
- name: OPENAI_MODEL
value: gpt-5-mini
- name: TOP_N_FILES - name: TOP_N_FILES
value: "10" value: "10"
- name: LAST_N_DAYS - name: LAST_N_DAYS

View File

@@ -2,50 +2,11 @@ package agent
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"mindforge.cronjob/internal/llm" "mindforge.cronjob/internal/llm"
) )
// Provider represents the LLM provider to use.
type Provider string
const (
ProviderOpenAI Provider = "openai"
ProviderGemini Provider = "gemini"
)
// providerFromEnv reads the provider for a given agent from an env var,
// defaulting to OpenAI if not set or unrecognised.
func providerFromEnv(envKey string) Provider {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envKey)))
if val == string(ProviderGemini) {
return ProviderGemini
}
return ProviderOpenAI
}
// send routes the request to the given LLM provider.
func send(provider Provider, systemPrompt, userPrompt string) (string, error) {
llmService := llm.NewLLMService()
switch provider {
case ProviderGemini:
geminiModel := os.Getenv("GEMINI_MODEL")
if geminiModel == "" {
geminiModel = "gemini-3.1-flash-lite-preview"
}
return llmService.SendGeminiRequest(systemPrompt, userPrompt, geminiModel)
default:
openaiModel := os.Getenv("OPENAI_MODEL")
if openaiModel == "" {
openaiModel = "gpt-5-mini"
}
return llmService.SendOpenAIRequest(systemPrompt, userPrompt, openaiModel)
}
}
// SummaryCreatorAgent creates a summary of the git diff for a specific file. // SummaryCreatorAgent creates a summary of the git diff for a specific file.
func SummaryCreatorAgent(filePath, gitDiff string) (string, error) { func SummaryCreatorAgent(filePath, gitDiff string) (string, error) {
fileName := filepath.Base(filePath) fileName := filepath.Base(filePath)
@@ -66,7 +27,7 @@ Responda sempre em Português do Brasil (pt-BR).`
userPrompt := fmt.Sprintf("Caminho do arquivo: %s\nPasta (Assunto Principal): %s\nArquivo (Assunto Específico): %s\n\nGit Diff:\n%s", filePath, folderName, fileName, gitDiff) userPrompt := fmt.Sprintf("Caminho do arquivo: %s\nPasta (Assunto Principal): %s\nArquivo (Assunto Específico): %s\n\nGit Diff:\n%s", filePath, folderName, fileName, gitDiff)
return send(providerFromEnv("SUMMARY_CREATOR_PROVIDER"), systemPrompt, userPrompt) return llm.NewLLMService().Send(systemPrompt, userPrompt)
} }
// SummaryFormatterAgent formats a plain text summary into Markdown. // SummaryFormatterAgent formats a plain text summary into Markdown.
@@ -82,5 +43,5 @@ Regras de formatação:
Responda sempre em Português do Brasil (pt-BR).` Responda sempre em Português do Brasil (pt-BR).`
return send(providerFromEnv("SUMMARY_FORMATTER_PROVIDER"), systemPrompt, summary) return llm.NewLLMService().Send(systemPrompt, summary)
} }

View File

@@ -1,86 +0,0 @@
package llm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func (s *llmService) SendGeminiRequest(systemPrompt string, userPrompt string, model string) (string, error) {
apiKey := getEnvConfig("GEMINI_API_KEY")
if apiKey == "" {
return "", errors.New("GEMINI_API_KEY not found in .env or environment")
}
apiBase := "https://generativelanguage.googleapis.com/v1beta"
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", strings.TrimRight(apiBase, "/"), model, apiKey)
reqBody := map[string]interface{}{}
if systemPrompt != "" {
reqBody["system_instruction"] = map[string]interface{}{
"parts": []map[string]string{
{"text": systemPrompt},
},
}
}
reqBody["contents"] = []map[string]interface{}{
{
"role": "user",
"parts": []map[string]string{
{"text": userPrompt},
},
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Gemini API error status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", err
}
if len(result.Candidates) > 0 && len(result.Candidates[0].Content.Parts) > 0 {
return result.Candidates[0].Content.Parts[0].Text, nil
}
return "", errors.New("empty response from Gemini API")
}

View File

@@ -6,8 +6,7 @@ import (
// Service defines the interface for connecting to LLMs // Service defines the interface for connecting to LLMs
type Service interface { type Service interface {
SendOpenAIRequest(systemPrompt string, userPrompt string, model string) (string, error) Send(systemPrompt string, userPrompt string) (string, error)
SendGeminiRequest(systemPrompt string, userPrompt string, model string) (string, error)
} }
type llmService struct{} type llmService struct{}

View File

@@ -11,15 +11,23 @@ import (
"time" "time"
) )
func (s *llmService) SendOpenAIRequest(systemPrompt string, userPrompt string, model string) (string, error) { func (s *llmService) Send(systemPrompt string, userPrompt string) (string, error) {
apiKey := getEnvConfig("OPENAI_API_KEY") apiURL := getEnvConfig("OPENAI_API_URL")
if apiKey == "" { if apiURL == "" {
return "", errors.New("OPENAI_API_KEY not found in .env or environment") return "", errors.New("OPENAI_API_URL not found in environment")
} }
apiBase := "https://api.openai.com/v1" token := getEnvConfig("OPENAI_TOKEN")
if token == "" {
return "", errors.New("OPENAI_TOKEN not found in environment")
}
url := fmt.Sprintf("%s/chat/completions", strings.TrimRight(apiBase, "/")) model := getEnvConfig("OPENAI_MODEL")
if model == "" {
return "", errors.New("OPENAI_MODEL not found in environment")
}
url := fmt.Sprintf("%s/chat/completions", strings.TrimRight(apiURL, "/"))
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"model": model, "model": model,
@@ -42,7 +50,7 @@ func (s *llmService) SendOpenAIRequest(systemPrompt string, userPrompt string, m
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 120 * time.Second} client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -62,7 +70,7 @@ func (s *llmService) SendOpenAIRequest(systemPrompt string, userPrompt string, m
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("OpenAI API error status %d: %s", resp.StatusCode, string(bodyBytes)) lastErr = fmt.Errorf("API error status %d: %s", resp.StatusCode, string(bodyBytes))
time.Sleep(time.Second * time.Duration(1<<i)) time.Sleep(time.Second * time.Duration(1<<i))
continue continue
} }
@@ -81,8 +89,8 @@ func (s *llmService) SendOpenAIRequest(systemPrompt string, userPrompt string, m
if len(result.Choices) > 0 { if len(result.Choices) > 0 {
return result.Choices[0].Message.Content, nil return result.Choices[0].Message.Content, nil
} }
return "", errors.New("empty response from OpenAI API") return "", errors.New("empty response from API")
} }
return "", fmt.Errorf("failed to get OpenAI response after 5 attempts. Last error: %v", lastErr) return "", fmt.Errorf("failed to get response after 5 attempts. Last error: %v", lastErr)
} }