diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs index cbabaeb..6008d4f 100644 --- a/PlotLine/Hubs/StoryIntelligenceHub.cs +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -1,13 +1,18 @@ 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) : Hub +public sealed class StoryIntelligenceHub( + IStoryIntelligenceService storyIntelligence, + IStoryIntelligenceResultRepository persistedRuns, + IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub { public override async Task OnConnectedAsync() { @@ -26,8 +31,80 @@ public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntellig return await storyIntelligence.GetProgressForUserAsync(userId, jobId); } + public async Task 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 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."); diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index 1711216..285ca98 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -65,6 +65,33 @@ public sealed class StoryIntelligenceJobProgress public bool IsCompleted { get; init; } } +public sealed class StoryIntelligenceRunProgressEvent +{ + public int RunID { get; init; } + public int UserID { get; init; } + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } + public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending; + public string CurrentStage { get; init; } = string.Empty; + public string CurrentMessage { get; init; } = string.Empty; + public string EventType { get; init; } = string.Empty; + public int? TotalDetectedScenes { get; init; } + public int? CompletedScenes { get; init; } + public int? FailedScenes { get; init; } + public int? TotalTokens { get; init; } + public long? TotalDurationMs { get; init; } + public string ElapsedTime { get; init; } = "0 sec"; + public string? EstimatedRemaining { get; init; } + public string? LatestSceneSummary { get; init; } + public IReadOnlyList RecentDiscoveries { get; init; } = []; + public string? ErrorMessage { get; init; } + public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow; + public bool IsActive { get; init; } + public bool IsCompleted { get; init; } +} + public sealed class StoryIntelligenceOptions { public string ApiKey { get; init; } = string.Empty; diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index cd94c17..a4c6cab 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -206,6 +206,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 0cb7caf..4f9489d 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -23,6 +23,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceImportCommitService commits, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, + IStoryIntelligenceProgressNotifier notifier, ICurrentUserService currentUser, ILogger logger) : IOnboardingStoryIntelligenceService { @@ -141,6 +142,23 @@ public sealed class OnboardingStoryIntelligenceService( ChapterID = chapterId, RunID = runId }); + + await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent + { + RunID = runId, + UserID = userId, + ProjectID = preview.ProjectID, + BookID = preview.BookID, + ChapterID = chapterId, + ChapterNumber = chapter.Decision.ChapterNumber, + Status = StoryIntelligenceRunStatuses.Pending, + CurrentStage = "Queued", + CurrentMessage = $"{chapter.Decision.Title} is queued for analysis.", + EventType = "Import started", + ElapsedTime = "0 sec", + EstimatedRemaining = "Calculating", + IsActive = true + }); } var batch = new OnboardingStoryIntelligenceBatch diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 8f0cd7f..054c194 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -21,6 +21,7 @@ public sealed class PersistedStoryIntelligenceRunner( IStorySceneValidator sceneValidator, IOptions options, IOptions pricingOptions, + IStoryIntelligenceProgressNotifier notifier, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; @@ -61,6 +62,14 @@ public sealed class PersistedStoryIntelligenceRunner( try { + await PublishAsync( + run, + "Import started", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.DocumentRead, + "Story Intelligence has started reading this chapter.", + stopwatch.ElapsedMilliseconds); + if (string.IsNullOrWhiteSpace(run.SourceText)) { throw new StoryIntelligenceRunFailureException( @@ -88,6 +97,13 @@ public sealed class PersistedStoryIntelligenceRunner( currentStage: StoryIntelligenceFailureStages.ChapterStructure, currentMessage: "Running Chapter Structure analysis.", totalDurationMs: stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Chapter started", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.ChapterStructure, + "PlotDirector is finding the scene boundaries in this chapter.", + stopwatch.ElapsedMilliseconds); var chapterClientResult = await client.ExecutePromptAsync( chapterPrompt, @@ -202,6 +218,17 @@ public sealed class PersistedStoryIntelligenceRunner( totalDurationMs: stopwatch.ElapsedMilliseconds, estimatedCostUSD: totals.EstimatedCostUSD, estimatedCostGBP: totals.EstimatedCostGBP); + await PublishAsync( + run, + "Scene boundaries detected", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.SceneSplit, + $"Detected {sceneBlocks.Count:N0} suggested scene(s).", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); foreach (var block in sceneBlocks) @@ -210,6 +237,17 @@ public sealed class PersistedStoryIntelligenceRunner( if (!block.SplitValid) { await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs."); + await PublishAsync( + run, + "Scene analysis failed", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.SceneIntelligence, + $"Suggested scene {block.TemporarySceneNumber:N0} could not be analysed because its paragraph range was invalid.", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens); continue; } @@ -220,6 +258,17 @@ public sealed class PersistedStoryIntelligenceRunner( completedScenes: completedScenes, failedScenes: failedScenes, totalDurationMs: stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Scene analysis started", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.SceneIntelligence, + $"Reading scene {block.TemporarySceneNumber:N0}.", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens); try { @@ -281,6 +330,19 @@ public sealed class PersistedStoryIntelligenceRunner( }); completedScenes++; + await PublishAsync( + run, + "Scene analysis completed", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.SceneIntelligence, + $"Scene {block.TemporarySceneNumber:N0} analysed.", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens, + latestSceneSummary: TrimOptional(sceneAttempt.Parsed?.Summary?.Short, 220), + recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); if (!validation.IsValid || validation.Warnings.Count > 0) { failedScenes += validation.Errors.Count > 0 ? 1 : 0; @@ -290,6 +352,18 @@ public sealed class PersistedStoryIntelligenceRunner( { failedScenes++; await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException); + await PublishAsync( + run, + "Scene analysis failed", + StoryIntelligenceRunStatuses.Running, + StoryIntelligenceFailureStages.SceneIntelligence, + $"Scene {block.TemporarySceneNumber:N0} needs attention.", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens, + errorMessage: ex.Message); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); } @@ -318,10 +392,30 @@ public sealed class PersistedStoryIntelligenceRunner( stopwatch.ElapsedMilliseconds, totals.EstimatedCostGBP, totals.EstimatedCostUSD); + await PublishAsync( + run, + finalStatus == StoryIntelligenceRunStatuses.Completed ? "Import completed" : "Chapter completed", + finalStatus, + StoryIntelligenceRunStatuses.Completed, + finalStatus == StoryIntelligenceRunStatuses.Completed + ? "Chapter analysis complete." + : "Chapter analysis complete with notes to review.", + stopwatch.ElapsedMilliseconds, + totalDetectedScenes: sceneBlocks.Count, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalTokens: totals.TotalTokens); } catch (StoryIntelligenceRunCancelledException) { await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Import cancelled", + StoryIntelligenceRunStatuses.Cancelled, + currentFailureStage, + "Analysis was cancelled.", + stopwatch.ElapsedMilliseconds); } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { @@ -331,10 +425,26 @@ public sealed class PersistedStoryIntelligenceRunner( "Story Intelligence request timed out or was cancelled before the run completed.", ex.ToString(), stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Import failed", + StoryIntelligenceRunStatuses.Failed, + currentFailureStage, + "Analysis could not finish.", + stopwatch.ElapsedMilliseconds, + errorMessage: ex.Message); } catch (StoryIntelligenceRunFailureException ex) { await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Import failed", + StoryIntelligenceRunStatuses.Failed, + ex.FailureStage, + "Analysis could not finish.", + stopwatch.ElapsedMilliseconds, + errorMessage: ex.Message); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -344,6 +454,14 @@ public sealed class PersistedStoryIntelligenceRunner( ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds); + await PublishAsync( + run, + "Import failed", + StoryIntelligenceRunStatuses.Failed, + ClassifyFailureStage(ex), + "Analysis could not finish.", + stopwatch.ElapsedMilliseconds, + errorMessage: ex.Message); } } @@ -358,6 +476,144 @@ public sealed class PersistedStoryIntelligenceRunner( } } + private Task PublishAsync( + StoryIntelligenceQueuedRun run, + string eventType, + string status, + string currentStage, + string currentMessage, + long elapsedMs, + int? totalDetectedScenes = null, + int? completedScenes = null, + int? failedScenes = null, + int? totalTokens = null, + string? latestSceneSummary = null, + IReadOnlyList? recentDiscoveries = null, + string? errorMessage = null) + => notifier.PublishAsync(new StoryIntelligenceRunProgressEvent + { + RunID = run.StoryIntelligenceRunID, + UserID = run.UserID, + ProjectID = run.ProjectID, + BookID = run.BookID, + ChapterID = run.ChapterID, + ChapterNumber = run.ChapterNumber, + Status = status, + CurrentStage = currentStage, + CurrentMessage = currentMessage, + EventType = eventType, + TotalDetectedScenes = totalDetectedScenes, + CompletedScenes = completedScenes, + FailedScenes = failedScenes, + TotalTokens = totalTokens, + TotalDurationMs = elapsedMs, + ElapsedTime = FormatDuration(elapsedMs), + EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0), + LatestSceneSummary = latestSceneSummary, + RecentDiscoveries = recentDiscoveries ?? [], + ErrorMessage = errorMessage, + UpdatedUtc = DateTime.UtcNow, + IsActive = status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running, + IsCompleted = 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 (totalScenes > 0 && completedScenes >= totalScenes) + { + return "Nearly finished"; + } + + if (completedScenes <= 0 || totalScenes <= 0) + { + return "Calculating"; + } + + var remainingScenes = Math.Max(0, totalScenes - completedScenes); + var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); + return FormatDuration(averageSceneMs * remainingScenes); + } + + private static IReadOnlyList BuildDiscoveries(SceneIntelligenceScene? scene) + { + if (scene is null) + { + return []; + } + + var discoveries = new List(); + discoveries.AddRange((scene.Characters ?? []) + .Select(character => TrimOptional(character.Name, 120)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .Select(name => $"Character detected: {name}")); + discoveries.AddRange((scene.Locations ?? []) + .Select(location => TrimOptional(location.Name, 120)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .Select(name => $"Location detected: {name}")); + discoveries.AddRange((scene.Assets ?? []) + .Select(asset => TrimOptional(asset.Name, 120)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2) + .Select(name => $"Asset detected: {name}")); + discoveries.AddRange((scene.Relationships ?? []) + .Select(relationship => BuildRelationshipDiscovery(relationship)) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .OfType() + .Take(2)); + discoveries.AddRange((scene.TimelineClues ?? []) + .Select(clue => TrimOptional(clue.Clue, 160)) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Take(2) + .Select(text => $"Timeline clue detected: {text}")); + + return discoveries + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToList(); + } + + private static string? BuildRelationshipDiscovery(SceneIntelligenceRelationship relationship) + { + var characterA = TrimOptional(relationship.CharacterA, 80); + var characterB = TrimOptional(relationship.CharacterB, 80); + var signal = TrimOptional(relationship.RelationshipSignal, 120); + if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB)) + { + return null; + } + + return string.IsNullOrWhiteSpace(signal) + ? $"Relationship detected: {characterA} and {characterB}" + : $"Relationship detected: {characterA} and {characterB} - {signal}"; + } + + private static string? TrimOptional(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd(); + } + private async Task> ParseAiJsonWithRetryAsync( string completedPrompt, string promptVersion, diff --git a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs new file mode 100644 index 0000000..fa6ed8a --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.SignalR; +using PlotLine.Hubs; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceProgressNotifier +{ + Task PublishAsync(StoryIntelligenceRunProgressEvent progress); +} + +public sealed class StoryIntelligenceProgressNotifier(IHubContext hub) : IStoryIntelligenceProgressNotifier +{ + public Task PublishAsync(StoryIntelligenceRunProgressEvent progress) + => hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID)) + .SendAsync("StoryIntelligenceRunProgressChanged", progress); +} diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index 7f2eeab..33f9913 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -8,7 +8,7 @@

@Model.SelectedBookTitle

Analyse chapters with Story Intelligence

-

PlotDirector will read the approved chapter text, detect scene boundaries, and prepare scenes for your review before anything is created.

+

PlotDirector can now analyse your manuscript to identify scenes and begin understanding your story.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -42,9 +42,9 @@

Chapter text ready for analysis.

- Creates - Scenes only -

Characters, locations, assets and relationships will not be imported yet.

+ Estimated time + @EstimateDuration(Model.ApprovedWordCount) +

The analysis runs in the background, so you can leave this page and return later.

Review @@ -53,6 +53,11 @@
+
+

It will extract scene summaries, scene purpose, characters, locations, assets, relationships, timeline clues and story observations.

+

For this step, only scenes are created in PlotDirector. The other discoveries are prepared for later review stages.

+
+ @if (!string.IsNullOrWhiteSpace(Model.Message)) {
@@ -75,3 +80,16 @@ }
+ +@functions { + private static string EstimateDuration(int wordCount) + { + if (wordCount <= 0) + { + return "A few minutes"; + } + + var minutes = Math.Clamp((int)Math.Ceiling(wordCount / 3000m) * 2, 2, 30); + return minutes <= 2 ? "About 2 minutes" : $"About {minutes:N0} minutes"; + } +} diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index ffe426d..687f5ac 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -1,19 +1,20 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review scenes"; + var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID)); + var progressPercent = ProgressPercent(Model); }
-
- @if (Model.HasActiveRuns) - { - - } - +

@Model.BookTitle

Review scenes

-

PlotDirector is analysing the approved chapters. When a chapter is ready, you can create its scenes in PlotDirector.

+

PlotDirector is reading the approved chapters and turning them into scenes you can review before creating them.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -27,60 +28,90 @@
- Chapters - @Model.CompletedChapterCount / @Model.ChapterCount + Overall progress + @progressPercent%
- Scenes detected - @Model.TotalDetectedScenes + Chapters completed + @Model.CompletedChapterCount / @Model.ChapterCount
Scenes analysed - @Model.TotalCompletedScenes + @Model.TotalCompletedScenes / @Model.TotalDetectedScenes
Failures - @(Model.FailedChapterCount + Model.TotalFailedScenes) + @(Model.FailedChapterCount + Model.TotalFailedScenes)
-
+
+
+
+ +
+
+

Reading now

+
+
+ Current chapter + @(CurrentChapter(Model)?.ChapterTitle ?? "Waiting") +
+
+ Current stage + @(CurrentChapter(Model)?.CurrentStage ?? "Queued") +
+
+ Elapsed + @Elapsed(Model) +
+
+ Estimated remaining + Calculating +
+
+

@(CurrentChapter(Model)?.CurrentMessage ?? "Waiting for analysis to begin.")

+ +
    +
    +

    Chapters

    @foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) { -
    +
    @chapter.ChapterNumber. @chapter.ChapterTitle - @ChapterStatusLabel(chapter) + @ChapterStatusLabel(chapter)
    Status - @chapter.Status + @chapter.Status
    Scenes - @((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0")) + @((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))
    Failed - @((chapter.FailedScenes ?? 0).ToString("N0")) + @((chapter.FailedScenes ?? 0).ToString("N0"))
    Tokens - @(chapter.TotalTokens?.ToString("N0") ?? "-") + @(chapter.TotalTokens?.ToString("N0") ?? "-")
    - @if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage)) - { -

    @chapter.CurrentMessage

    - } +

    @(chapter.CurrentMessage ?? string.Empty)

    @if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage)) { -

    @chapter.ErrorMessage

    +

    @chapter.ErrorMessage

    + } + else + { + } @if (chapter.Warnings.Any()) { @@ -132,7 +163,6 @@ - Refresh
    +@section Scripts { + +} + @functions { private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter) { @@ -167,4 +201,33 @@ return "Reviewing"; } + + private static StoryIntelligenceOnboardingChapterViewModel? CurrentChapter(StoryIntelligenceProgressViewModel model) + => model.Chapters.FirstOrDefault(chapter => chapter.IsActive) + ?? model.Chapters.LastOrDefault(chapter => chapter.IsRunComplete) + ?? model.Chapters.FirstOrDefault(); + + private static int ProgressPercent(StoryIntelligenceProgressViewModel model) + { + if (model.ChapterCount == 0) + { + return 0; + } + + if (model.TotalDetectedScenes > 0) + { + return Math.Clamp((int)Math.Round(model.TotalCompletedScenes * 100m / Math.Max(1, model.TotalDetectedScenes)), 0, 100); + } + + return Math.Clamp((int)Math.Round(model.CompletedChapterCount * 100m / Math.Max(1, model.ChapterCount)), 0, 100); + } + + private static string Elapsed(StoryIntelligenceProgressViewModel model) + { + var elapsedMs = model.Chapters.Max(chapter => chapter.TotalDurationMs ?? 0); + var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, elapsedMs)); + return elapsed.TotalMinutes >= 1 + ? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min" + : $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec"; + } } diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js index 6dacae8..bf01b90 100644 --- a/PlotLine/wwwroot/js/story-intelligence-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-progress.js @@ -1,15 +1,26 @@ (() => { const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")]; const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")]; - const roots = [...progressRoots, ...dashboardRoots]; + const onboardingRoots = [...document.querySelectorAll("[data-story-intelligence-onboarding]")]; + const legacyRoots = [...progressRoots, ...dashboardRoots]; + const roots = [...legacyRoots, ...onboardingRoots]; if (roots.length === 0 || !window.signalR) { return; } const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null; - const jobIds = [...new Set(roots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))]; + const legacyJobIds = [...new Set(legacyRoots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))]; + const runIds = [...new Set(onboardingRoots + .flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(",")) + .map((value) => value.trim()) + .filter(Boolean))]; - const updateRoot = (root, state) => { + const numberText = (value) => { + const number = Number.parseInt(value ?? "0", 10); + return Number.isFinite(number) ? number.toLocaleString() : "0"; + }; + + const updateLegacyRoot = (root, state) => { const percent = field(state, "progressPercent", "ProgressPercent") ?? 0; const status = field(state, "status", "Status") || "Pending"; const stage = field(state, "currentStage", "CurrentStage") || status; @@ -46,33 +57,210 @@ }); }; - const applyProgress = (state) => { + const applyLegacyProgress = (state) => { const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId"); if (!jobId) { return; } - roots + legacyRoots .filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase()) - .forEach((root) => updateRoot(root, state)); + .forEach((root) => updateLegacyRoot(root, state)); if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) { window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`; } }; + const statusLabel = (state) => { + const status = field(state, "status", "Status") || ""; + if (status === "Completed" || status === "CompletedWithWarnings") { + return "Ready to create"; + } + if (status === "Failed" || status === "Cancelled") { + return "Needs attention"; + } + if (status === "Pending" || status === "Running") { + return "Analysing"; + } + return "Reviewing"; + }; + + const addFeedItem = (list, text) => { + if (!list || !text) { + return; + } + + const item = document.createElement("li"); + item.textContent = text; + list.prepend(item); + while (list.children.length > 8) { + list.lastElementChild?.remove(); + } + }; + + const recalculateOnboardingTotals = (root) => { + const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true"; + const chapters = [...root.querySelectorAll("[data-story-run-id]")]; + const totals = chapters.reduce((current, chapter) => { + const status = chapter.querySelector("[data-story-run-status]")?.textContent?.trim() || ""; + const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; + const total = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; + const failed = Number.parseInt(chapter.querySelector("[data-story-run-failed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; + current.completedChapters += status === "Completed" || status === "CompletedWithWarnings" ? 1 : 0; + current.failedChapters += status === "Failed" || status === "Cancelled" ? 1 : 0; + current.completedScenes += completed; + current.totalScenes += total; + current.failedScenes += failed; + return current; + }, { completedChapters: 0, failedChapters: 0, completedScenes: 0, totalScenes: 0, failedScenes: 0 }); + + const percent = totals.totalScenes > 0 + ? Math.round((totals.completedScenes * 100) / Math.max(1, totals.totalScenes)) + : Math.round((totals.completedChapters * 100) / Math.max(1, chapters.length)); + + root.querySelectorAll("[data-story-overall-percent]").forEach((node) => { + node.textContent = `${Math.max(0, Math.min(100, percent))}%`; + }); + root.querySelectorAll("[data-story-overall-progress-bar]").forEach((node) => { + node.style.width = `${Math.max(0, Math.min(100, percent))}%`; + }); + root.querySelectorAll("[data-story-chapters-completed]").forEach((node) => { + node.textContent = numberText(totals.completedChapters); + }); + root.querySelectorAll("[data-story-scenes-completed]").forEach((node) => { + node.textContent = numberText(totals.completedScenes); + }); + root.querySelectorAll("[data-story-scenes-total]").forEach((node) => { + node.textContent = numberText(totals.totalScenes); + }); + root.querySelectorAll("[data-story-failures]").forEach((node) => { + node.textContent = numberText(totals.failedChapters + totals.failedScenes); + }); + + const active = chapters.some((chapter) => { + const status = chapter.querySelector("[data-story-run-status]")?.textContent?.trim(); + return status === "Pending" || status === "Running"; + }); + root.dataset.storyIntelligenceHasActiveRuns = String(active); + + if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { + root.dataset.reloadScheduled = "true"; + window.setTimeout(() => window.location.reload(), 1200); + } + }; + + const applyRunProgress = (state) => { + const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId"); + if (!runId) { + return; + } + + onboardingRoots.forEach((root) => { + const chapter = root.querySelector(`[data-story-run-id="${runId}"]`); + if (!chapter) { + return; + } + + const status = field(state, "status", "Status") || "Pending"; + const stage = field(state, "currentStage", "CurrentStage") || status; + const message = field(state, "currentMessage", "CurrentMessage") || ""; + const completed = field(state, "completedScenes", "CompletedScenes"); + const total = field(state, "totalDetectedScenes", "TotalDetectedScenes"); + const failed = field(state, "failedScenes", "FailedScenes"); + const tokens = field(state, "totalTokens", "TotalTokens"); + const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; + const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating"; + const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); + const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || []; + const error = field(state, "errorMessage", "ErrorMessage"); + + chapter.querySelectorAll("[data-story-chapter-status-label]").forEach((node) => { + node.textContent = statusLabel(state); + }); + chapter.querySelectorAll("[data-story-run-status]").forEach((node) => { + node.textContent = status; + }); + chapter.querySelectorAll("[data-story-run-message]").forEach((node) => { + node.textContent = message; + }); + chapter.querySelectorAll("[data-story-run-completed]").forEach((node) => { + if (completed !== null) { + node.textContent = numberText(completed); + } + }); + chapter.querySelectorAll("[data-story-run-total]").forEach((node) => { + if (total !== null) { + node.textContent = numberText(total); + } + }); + chapter.querySelectorAll("[data-story-run-failed]").forEach((node) => { + if (failed !== null) { + node.textContent = numberText(failed); + } + }); + chapter.querySelectorAll("[data-story-run-tokens]").forEach((node) => { + node.textContent = tokens === null ? "-" : numberText(tokens); + }); + chapter.querySelectorAll("[data-story-run-error]").forEach((node) => { + node.textContent = error || ""; + node.hidden = !error; + }); + + root.querySelectorAll("[data-story-current-chapter]").forEach((node) => { + node.textContent = chapter.dataset.storyChapterTitle || "Current chapter"; + }); + root.querySelectorAll("[data-story-current-stage]").forEach((node) => { + node.textContent = stage; + }); + root.querySelectorAll("[data-story-current-message]").forEach((node) => { + node.textContent = message; + }); + root.querySelectorAll("[data-story-elapsed]").forEach((node) => { + node.textContent = elapsed; + }); + root.querySelectorAll("[data-story-remaining]").forEach((node) => { + node.textContent = remaining; + }); + + const summaryNode = root.querySelector("[data-story-latest-summary]"); + if (summaryNode && latestSummary) { + summaryNode.textContent = latestSummary; + summaryNode.hidden = false; + } + + const feed = root.querySelector("[data-story-discovery-feed]"); + [...discoveries].reverse().forEach((discovery) => addFeedItem(feed, discovery)); + + recalculateOnboardingTotals(root); + }); + }; + const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/story-intelligence") .withAutomaticReconnect() .build(); - connection.on("StoryIntelligenceProgressChanged", applyProgress); + connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress); + connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress); connection.onreconnected(() => { - jobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyProgress).catch(() => {})); + legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {})); + runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {})); }); connection.start() - .then(() => Promise.all(jobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)))) - .then((states) => states.filter(Boolean).forEach(applyProgress)) + .then(() => Promise.all([ + ...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)), + ...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))) + ])) + .then((states) => { + states.filter(Boolean).forEach((state) => { + if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) { + applyRunProgress(state); + } else { + applyLegacyProgress(state); + } + }); + }) .catch(() => {}); })();