Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ services:
- MEMORIZER_LLM__Chunking__MinCharactersForChunking=2000
- MEMORIZER_LLM__Chunking__TargetChunkSize=1500
- MEMORIZER_Server__CanonicalUrl=http://localhost:5000
- MEMORIZER_MarkdownExport__RootPath=/data/markdown
- MEMORIZER_MarkdownExport__AutoSyncOnStartup=false
- ASPNETCORE_ENVIRONMENT=Development
ports:
- "5000:8080"
Expand Down
206 changes: 206 additions & 0 deletions src/Memorizer/Actors/MarkdownExportActor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using Akka.Actor;
using Akka.Event;
using Akka.Streams;
using Memorizer.Services;
using Microsoft.Extensions.DependencyInjection;

namespace Memorizer.Actors;

public sealed class MarkdownExportActor : ReceiveActor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILoggingAdapter _logger;
private readonly IMaterializer _materializer;

private ProgressJobManager? _jobManager;
private IServiceScope? _currentScope;

public MarkdownExportActor(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = Context.GetLogger();
_materializer = Context.System.Materializer();

Idle();
}

private void Idle()
{
ReceiveAsync<StartMarkdownExport>(HandleStartExport);

Receive<SubscribeToProgress>(msg =>
{
_logger.Debug("Subscription requested while idle, subscriber: {0}", msg.SubscriberId);
var tempManager = new ProgressJobManager(_logger, _materializer);
var reader = tempManager.CreateIdleSubscription(msg.SubscriberId);
Sender.Tell(new ProgressSubscription(msg.SubscriberId, reader));
});

Receive<UnsubscribeFromProgress>(msg =>
{
_logger.Debug("Unsubscribe requested while idle, subscriber: {0}", msg.SubscriberId);
});

Receive<GetMarkdownExportStatus>(_ => HandleGetStatusIdle());
}

private void Running()
{
Receive<StartMarkdownExport>(msg =>
{
_logger.Warning("Markdown export already running, rejecting new request from {0}", msg.RequestedBy);
Sender.Tell(new MarkdownExportStatus(
IsRunning: true,
Status: "Already running"
));
});

Receive<SubscribeToProgress>(msg =>
{
if (_jobManager != null)
{
_logger.Debug("Adding subscriber to running job: {0}", msg.SubscriberId);
var reader = _jobManager.AddSubscriber(msg.SubscriberId);
Sender.Tell(new ProgressSubscription(msg.SubscriberId, reader));
}
});

Receive<UnsubscribeFromProgress>(msg =>
{
_logger.Debug("Removing subscriber: {0}", msg.SubscriberId);
_jobManager?.RemoveSubscriber(msg.SubscriberId);
});

Receive<GetMarkdownExportStatus>(_ => HandleGetStatusRunning());
Receive<MarkdownExportCompleted>(HandleExportCompleted);
}

private async Task HandleStartExport(StartMarkdownExport message)
{
var sender = Sender;
var self = Self;

_logger.Info("Starting markdown export, requested by {0}", message.RequestedBy);

try
{
_currentScope = _serviceProvider.CreateScope();
var exportService = _currentScope.ServiceProvider.GetRequiredService<IMarkdownExportService>();

if (!exportService.IsEnabled)
{
sender.Tell(new MarkdownExportStatus(
IsRunning: false,
Status: "Feature disabled - RootPath not configured"
));
_currentScope.Dispose();
_currentScope = null;
return;
}

_jobManager = new ProgressJobManager(_logger, _materializer);
_jobManager.StartJob(1, message.RequestedBy);

Become(Running);

sender.Tell(new MarkdownExportStatus(
IsRunning: true,
Status: "Running",
TotalProcessed: 0,
TotalSuccessful: 0,
TotalFailed: 0,
Outstanding: 1,
StartTime: _jobManager.StartTime,
Duration: TimeSpan.Zero,
RequestedBy: message.RequestedBy
));

var progress = new Progress<ExportProgress>(p =>
{
// Update job manager with progress - we track total items dynamically
if (p.TotalItems > 0 && _jobManager.TotalItems != p.TotalItems)
{
// Re-initialize with correct total
// Note: ProgressJobManager doesn't support changing total mid-job,
// but we broadcast via the SSE event
}
});

var result = await exportService.ExportAllAsync(
message.WorkspaceFilter,
message.ProjectFilter,
progress,
CancellationToken.None);

var completed = new MarkdownExportCompleted(
RequestedBy: message.RequestedBy,
StartTime: _jobManager.StartTime,
TotalExported: result.TotalExported,
TotalFailed: result.TotalFailed,
TotalSkipped: result.TotalSkipped,
Duration: DateTime.UtcNow - _jobManager.StartTime
);

self.Tell(completed);
}
catch (Exception ex)
{
_logger.Error(ex, "Error during markdown export: {0}", ex.Message);
sender.Tell(new MarkdownExportStatus(
IsRunning: false,
Status: "Failed: " + ex.Message
));
_jobManager?.Fail(ex.Message);
_jobManager = null;
_currentScope?.Dispose();
_currentScope = null;
Become(Idle);
}
}

private void HandleExportCompleted(MarkdownExportCompleted message)
{
_logger.Info("Markdown export completed: {0} exported, {1} failed, {2} skipped, duration: {3}ms",
message.TotalExported, message.TotalFailed, message.TotalSkipped, message.Duration.TotalMilliseconds);

Context.System.EventStream.Publish(message);

_jobManager?.RecordSuccess();
_jobManager?.Complete();
_jobManager = null;

_currentScope?.Dispose();
_currentScope = null;

Become(Idle);
}

private void HandleGetStatusIdle()
{
Sender.Tell(new MarkdownExportStatus(
IsRunning: false,
Status: "idle"
));
}

private void HandleGetStatusRunning()
{
if (_jobManager == null)
{
HandleGetStatusIdle();
return;
}

Sender.Tell(new MarkdownExportStatus(
IsRunning: true,
Status: "Running",
TotalProcessed: _jobManager.ProcessedCount,
TotalSuccessful: _jobManager.SuccessCount,
TotalFailed: _jobManager.FailureCount,
Outstanding: _jobManager.TotalItems - _jobManager.ProcessedCount,
StartTime: _jobManager.StartTime,
Duration: DateTime.UtcNow - _jobManager.StartTime,
RequestedBy: _jobManager.RequestedBy
));
}
}
36 changes: 36 additions & 0 deletions src/Memorizer/Actors/MarkdownExportMessages.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Memorizer.Models;

namespace Memorizer.Actors;

public sealed record StartMarkdownExport
{
public required string RequestedBy { get; init; }
public WorkspaceId? WorkspaceFilter { get; init; }
public ProjectId? ProjectFilter { get; init; }
}

public record GetMarkdownExportStatus;

public record MarkdownExportStatus(
bool IsRunning,
string Status,
int? TotalProcessed = null,
int? TotalSuccessful = null,
int? TotalFailed = null,
int? TotalSkipped = null,
int? Outstanding = null,
DateTime? StartTime = null,
TimeSpan? Duration = null,
string? RequestedBy = null
);

public sealed record MarkdownExportCompleted(
string RequestedBy,
DateTime StartTime,
int TotalExported,
int TotalFailed,
int TotalSkipped,
TimeSpan Duration
);

public sealed class MarkdownExportActorKey;
91 changes: 91 additions & 0 deletions src/Memorizer/Controllers/ToolsController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Akka.Actor;
using Akka.Hosting;
using Memorizer.Actors;
using Memorizer.Models;
using Memorizer.Services;
using Memorizer.Settings;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -16,9 +17,11 @@ public class ToolsController : Controller
private readonly IActorRef _embeddingRegenerationActor;
private readonly IActorRef _versionPurgeActor;
private readonly IActorRef _dimensionMigrationActor;
private readonly IActorRef _markdownExportActor;
private readonly IOptionsSnapshot<LlmSettings> _llmSettingsSnapshot;
private readonly IEmbeddingDimensionService _dimensionService;
private readonly IDimensionMismatchState _mismatchState;
private readonly MarkdownExportSettings _markdownExportSettings;
private readonly ILogger<ToolsController> _logger;

// Convenience property to access current settings
Expand All @@ -29,18 +32,22 @@ public ToolsController(
IRequiredActor<EmbeddingRegenerationActorKey> embeddingRegenerationActor,
IRequiredActor<VersionPurgeActorKey> versionPurgeActor,
IRequiredActor<DimensionMigrationActorKey> dimensionMigrationActor,
IRequiredActor<MarkdownExportActorKey> markdownExportActor,
IOptionsSnapshot<LlmSettings> llmSettingsSnapshot,
IEmbeddingDimensionService dimensionService,
IDimensionMismatchState mismatchState,
MarkdownExportSettings markdownExportSettings,
ILogger<ToolsController> logger)
{
_titleGenerationActor = titleGenerationActor.ActorRef;
_embeddingRegenerationActor = embeddingRegenerationActor.ActorRef;
_versionPurgeActor = versionPurgeActor.ActorRef;
_dimensionMigrationActor = dimensionMigrationActor.ActorRef;
_markdownExportActor = markdownExportActor.ActorRef;
_llmSettingsSnapshot = llmSettingsSnapshot;
_dimensionService = dimensionService;
_mismatchState = mismatchState;
_markdownExportSettings = markdownExportSettings;
_logger = logger;
}

Expand Down Expand Up @@ -557,4 +564,88 @@ public class ResumeMigrationRequest
}

#endregion

#region Markdown Export

[HttpGet]
[Route("markdown-export")]
public IActionResult MarkdownExport()
{
ViewData["IsEnabled"] = !string.IsNullOrWhiteSpace(_markdownExportSettings.RootPath);
ViewData["RootPath"] = _markdownExportSettings.RootPath ?? "(not configured)";
return View();
}

[HttpPost]
[Route("start-markdown-export")]
public async Task<IActionResult> StartMarkdownExport(Guid? workspaceId = null, Guid? projectId = null)
{
try
{
if (string.IsNullOrWhiteSpace(_markdownExportSettings.RootPath))
{
return Json(new { success = false, message = "Markdown export is not configured. Set MarkdownExport:RootPath in configuration." });
}

var status = await _markdownExportActor.Ask<MarkdownExportStatus>(new GetMarkdownExportStatus(), TimeSpan.FromSeconds(5));
if (status.IsRunning)
{
return Json(new {
success = false,
message = "A markdown export is already in progress.",
isRunning = true
});
}

var startMessage = new StartMarkdownExport
{
RequestedBy = User.Identity?.Name ?? "Anonymous",
WorkspaceFilter = workspaceId.HasValue ? new WorkspaceId(workspaceId.Value) : null,
ProjectFilter = projectId.HasValue ? new ProjectId(projectId.Value) : null
};

var startStatus = await _markdownExportActor.Ask<MarkdownExportStatus>(
startMessage, TimeSpan.FromSeconds(30));

return Json(new {
success = true,
message = "Markdown export started",
isRunning = startStatus.IsRunning
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting markdown export: {Error}", ex.Message);
return Json(new { success = false, message = $"Error: {ex.Message}" });
}
}

[HttpGet]
[Route("markdown-export-status")]
public async Task<IActionResult> GetMarkdownExportStatus()
{
try
{
var status = await _markdownExportActor.Ask<MarkdownExportStatus>(new GetMarkdownExportStatus(), TimeSpan.FromSeconds(5));
return Json(new {
success = true,
status = status.Status,
isRunning = status.IsRunning,
outstanding = status.Outstanding,
totalProcessed = status.TotalProcessed,
totalSuccessful = status.TotalSuccessful,
totalFailed = status.TotalFailed,
requestedBy = status.RequestedBy,
startTime = status.StartTime,
duration = status.Duration?.TotalSeconds
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting markdown export status: {Error}", ex.Message);
return Json(new { success = false, message = $"Error: {ex.Message}" });
}
}

#endregion
}
Loading