PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml

365 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Reading your manuscript";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
var currentChapter = CurrentChapter(Model);
}
<section class="onboarding-shell" aria-labelledby="story-progress-title">
<div class="onboarding-panel onboarding-review-panel story-live-progress"
data-story-intelligence-onboarding
data-story-intelligence-batch-id="@Model.BatchID"
data-story-intelligence-run-ids="@runIds"
data-story-max-progress-percent="@progressPercent"
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Analyse Manuscript" })" />
<div class="onboarding-copy story-live-hero">
<p class="eyebrow">Analyse manuscript</p>
<h1 id="story-progress-title">Reading your manuscript</h1>
<p>PlotDirector is analysing your chapters and preparing scenes for review. You can safely leave this page and return later.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<section class="alert alert-info">
PlotDirector will keep analysing your manuscript. You can close Word, close this browser tab, and return later. Well show this import on your Projects page while it is running.
</section>
<section class="story-live-progress-card" aria-label="Import progress">
<div class="story-live-progress-heading">
<div>
<p class="eyebrow mb-1">Import progress</p>
<strong data-story-overall-percent>@progressPercent%</strong>
</div>
<span data-story-current-stage>@FriendlyStage(currentChapter?.CurrentStage, currentChapter?.Status)</span>
</div>
<div class="progress" role="progressbar" aria-label="Story Intelligence progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@progressPercent">
<div class="progress-bar" data-story-overall-progress-bar style="width:@progressPercent%"></div>
</div>
<div class="story-live-stats">
<div>
<span>Chapters read</span>
<strong class="story-stat-value" data-story-chapters-read-value>@Model.CompletedChapterCount of @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes read</span>
<strong class="story-stat-value" data-story-scenes-read-value>@Model.TotalCompletedScenes so far</strong>
</div>
<div>
<span>Needs review</span>
<strong class="story-stat-value" data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div>
<div>
<span>Elapsed</span>
<strong class="story-stat-value" data-story-elapsed>@Elapsed(Model)</strong>
</div>
<div>
<span>Estimated remaining</span>
<strong class="story-stat-value" data-story-remaining>@EstimateRemaining(Model)</strong>
</div>
</div>
</section>
<section class="story-live-current" aria-label="Current activity">
<div>
<p class="eyebrow">Current chapter</p>
<h2 data-story-current-chapter>@(currentChapter?.ChapterTitle ?? "Waiting")</h2>
<p data-story-current-message>@FriendlyMessage(currentChapter)</p>
</div>
<div class="story-current-scene-panel">
<p class="eyebrow">Current scene</p>
<strong data-story-current-scene>@CurrentScene(Model)</strong>
<div class="story-chapter-progress">
<span data-story-chapter-progress-text>@ChapterProgressText(currentChapter)</span>
<div class="progress" role="progressbar" aria-label="Current chapter progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@ChapterProgressPercent(currentChapter)">
<div class="progress-bar" data-story-chapter-progress-bar style="width:@ChapterProgressPercent(currentChapter)%"></div>
</div>
</div>
</div>
</section>
<section class="story-live-feed-grid" aria-label="Live manuscript feed">
<div>
<h2>Latest from your manuscript</h2>
@if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary))
{
<p class="lead story-latest-summary" data-story-latest-summary>@currentChapter.LatestSceneSummary</p>
<p class="text-muted" data-story-latest-empty hidden>Scene summaries will appear here as PlotDirector reads.</p>
}
else
{
<p class="lead story-latest-summary" data-story-latest-summary hidden></p>
<p class="text-muted" data-story-latest-empty>Scene summaries will appear here as PlotDirector reads.</p>
}
</div>
<div class="story-live-discoveries">
<h2>Recent discoveries</h2>
<p class="text-muted story-discovery-placeholder" data-story-discovery-empty>Possible characters, locations and story clues will appear here as PlotDirector reads.</p>
<ul data-story-discovery-feed></ul>
</div>
</section>
<div class="story-run-state" aria-hidden="true" hidden>
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-number="@(chapter.CompletedScenes ?? 0)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span>
<span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@FriendlyMessage(chapter)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div>
}
</div>
<div class="onboarding-actions">
@if (Model.BatchID.HasValue)
{
<form asp-action="CancelStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form>
@if (Model.HasActiveRuns)
{
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Index">Continue in background</a>
}
@if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
{
<a class="btn btn-primary"
data-story-review-link
asp-action="StoryIntelligenceReview"
asp-route-batchId="@Model.BatchID">Review scenes</a>
}
}
</div>
</div>
</section>
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
}
@functions {
private static string FriendlyStatus(string? status)
=> status switch
{
StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Ready to create",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Ready",
StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Waiting"
};
private static string FriendlyStage(string? stage, string? status)
{
if (status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)
{
return "Ready to review";
}
if (status is StoryIntelligenceRunStatuses.Failed)
{
return "Needs review";
}
if (status is StoryIntelligenceRunStatuses.Cancelled)
{
return "Cancelled";
}
return stage switch
{
"Queued" => "Waiting",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading scenes",
StoryIntelligenceFailureStages.SceneSplit => "Preparing review",
StoryIntelligenceFailureStages.Validation => "Preparing review",
StoryIntelligenceFailureStages.Persistence => "Preparing review",
_ => "Reading"
};
}
private static string FriendlyMessage(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
if (chapter is null)
{
return "Waiting for analysis to begin.";
}
if (chapter.Status == StoryIntelligenceRunStatuses.Cancelled)
{
return "Analysis was cancelled. Any completed chapters remain available for review.";
}
if (chapter.Status == StoryIntelligenceRunStatuses.Failed)
{
return "This chapter needs review before scenes can be created.";
}
return chapter.CurrentStage switch
{
"Queued" => "Waiting to read this chapter.",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes in this chapter.",
StoryIntelligenceFailureStages.SceneSplit => "Preparing the detected scenes.",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading the detected scenes.",
StoryIntelligenceFailureStages.Validation => "Checking the results.",
_ => chapter.IsRunComplete
? "This chapter is ready to review."
: "Waiting for analysis to begin."
};
}
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;
}
var chapterProgress = model.Chapters.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / model.ChapterCount), 0, 100);
}
private static decimal ChapterProgress(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit || chapter.IsRunComplete || chapter.IsFailed)
{
return 1m;
}
if (chapter.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((chapter.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((chapter.CompletedScenes ?? 0) / Math.Max(1m, chapter.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
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";
}
private static string EstimateRemaining(StoryIntelligenceProgressViewModel model)
{
if (!model.HasActiveRuns)
{
return "Ready to review";
}
var completed = model.Chapters
.Where(chapter => chapter.IsRunComplete && (chapter.SourceWordCount ?? 0) > 0 && (chapter.TotalDurationMs ?? 0) > 0)
.ToList();
if (completed.Count == 0)
{
return "Estimating...";
}
var completedWords = completed.Sum(chapter => chapter.SourceWordCount ?? 0);
var completedSeconds = completed.Sum(chapter => chapter.TotalDurationMs ?? 0) / 1000m;
var remainingWords = model.Chapters
.Where(chapter => !chapter.IsRunComplete && !chapter.IsFailed)
.Sum(chapter => chapter.SourceWordCount ?? 0);
if (completedWords <= 0 || completedSeconds <= 0 || remainingWords <= 0)
{
return "Estimating...";
}
var secondsPerWord = completedSeconds / completedWords;
var bufferedSeconds = secondsPerWord * remainingWords * 1.35m;
return FriendlyRemaining(bufferedSeconds);
}
private static string FriendlyRemaining(decimal seconds)
{
var minutes = (int)Math.Ceiling(seconds / 60m);
if (minutes <= 0)
{
return "Estimating...";
}
if (minutes <= 20)
{
return "About 20-30 minutes";
}
if (minutes <= 45)
{
return "About 45-60 minutes";
}
if (minutes <= 120)
{
return "About 1-2 hours";
}
return "More than 2 hours";
}
private static string CurrentScene(StoryIntelligenceProgressViewModel model)
{
var chapter = CurrentChapter(model);
return chapter?.CompletedScenes is > 0
? chapter.CompletedScenes.Value.ToString("N0")
: "Waiting";
}
private static string ChapterProgressText(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
if ((chapter?.TotalDetectedScenes ?? 0) <= 0)
{
return "Finding scenes...";
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0);
return $"Scene {completed:N0} of {chapter?.TotalDetectedScenes:N0}";
}
private static int ChapterProgressPercent(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
var total = chapter?.TotalDetectedScenes ?? 0;
if (total <= 0)
{
return 0;
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, total);
return Math.Clamp((int)Math.Floor(completed * 100m / total), 0, 100);
}
}