PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs

114 lines
4.4 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Hubs;
[Authorize]
public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
{
public override async Task OnConnectedAsync()
{
if (TryGetUserId(out var userId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
}
await base.OnConnectedAsync();
}
public async Task<StoryIntelligenceJobProgress?> WatchStoryIntelligenceJob(Guid jobId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
return await storyIntelligence.GetProgressForUserAsync(userId, jobId);
}
public async Task<StoryIntelligenceRunProgressEvent?> WatchStoryIntelligenceRun(int runId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await persistedRuns.GetRunAsync(runId);
return run is null || run.UserID != userId
? null
: ToRunProgress(run, "Current status");
}
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
return await onboardingStoryIntelligence.GetProgressAsync(batchId);
}
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType)
{
var completedScenes = run.CompletedScenes ?? 0;
var totalScenes = run.TotalDetectedScenes ?? 0;
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
return new StoryIntelligenceRunProgressEvent
{
RunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = run.Status,
CurrentStage = run.CurrentStage ?? string.Empty,
CurrentMessage = run.CurrentMessage ?? string.Empty,
EventType = eventType,
TotalDetectedScenes = run.TotalDetectedScenes,
CompletedScenes = run.CompletedScenes,
FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens,
TotalDurationMs = elapsedMs,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
ErrorMessage = run.ErrorMessage,
UpdatedUtc = run.UpdatedUtc,
IsActive = run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running,
IsCompleted = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
};
}
private static string FormatDuration(long? milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
if (elapsed.TotalMinutes >= 1)
{
return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min";
}
return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (completedScenes <= 0 || totalScenes <= completedScenes)
{
return totalScenes > 0 && totalScenes == completedScenes ? "Nearly finished" : "Calculating";
}
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageSceneMs * (totalScenes - completedScenes));
}
private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");
private bool TryGetUserId(out int userId)
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
}