Phase 20AB – Resume the Story Intelligence Roadmap

This commit is contained in:
Nick Beckley 2026-07-06 20:09:00 +01:00
parent 4b7af17b42
commit 8d493afb43
9 changed files with 706 additions and 41 deletions

View File

@ -1,13 +1,18 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Hubs; namespace PlotLine.Hubs;
[Authorize] [Authorize]
public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntelligence) : Hub public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
{ {
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
@ -26,8 +31,80 @@ public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntellig
return await storyIntelligence.GetProgressForUserAsync(userId, jobId); 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}"; 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() private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence."); => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");

View File

@ -65,6 +65,33 @@ public sealed class StoryIntelligenceJobProgress
public bool IsCompleted { get; init; } 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<string> 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 sealed class StoryIntelligenceOptions
{ {
public string ApiKey { get; init; } = string.Empty; public string ApiKey { get; init; } = string.Empty;

View File

@ -206,6 +206,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>(); builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>(); builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>(); builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>(); builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>(); builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();

View File

@ -23,6 +23,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceImportCommitService commits, IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
ICurrentUserService currentUser, ICurrentUserService currentUser,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{ {
@ -141,6 +142,23 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId, ChapterID = chapterId,
RunID = runId 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 var batch = new OnboardingStoryIntelligenceBatch

View File

@ -21,6 +21,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStorySceneValidator sceneValidator, IStorySceneValidator sceneValidator,
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{ {
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -61,6 +62,14 @@ public sealed class PersistedStoryIntelligenceRunner(
try try
{ {
await PublishAsync(
run,
"Import started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence has started reading this chapter.",
stopwatch.ElapsedMilliseconds);
if (string.IsNullOrWhiteSpace(run.SourceText)) if (string.IsNullOrWhiteSpace(run.SourceText))
{ {
throw new StoryIntelligenceRunFailureException( throw new StoryIntelligenceRunFailureException(
@ -88,6 +97,13 @@ public sealed class PersistedStoryIntelligenceRunner(
currentStage: StoryIntelligenceFailureStages.ChapterStructure, currentStage: StoryIntelligenceFailureStages.ChapterStructure,
currentMessage: "Running Chapter Structure analysis.", currentMessage: "Running Chapter Structure analysis.",
totalDurationMs: stopwatch.ElapsedMilliseconds); 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( var chapterClientResult = await client.ExecutePromptAsync(
chapterPrompt, chapterPrompt,
@ -202,6 +218,17 @@ public sealed class PersistedStoryIntelligenceRunner(
totalDurationMs: stopwatch.ElapsedMilliseconds, totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: totals.EstimatedCostUSD, estimatedCostUSD: totals.EstimatedCostUSD,
estimatedCostGBP: totals.EstimatedCostGBP); 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); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks) foreach (var block in sceneBlocks)
@ -210,6 +237,17 @@ public sealed class PersistedStoryIntelligenceRunner(
if (!block.SplitValid) if (!block.SplitValid)
{ {
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs."); 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; continue;
} }
@ -220,6 +258,17 @@ public sealed class PersistedStoryIntelligenceRunner(
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalDurationMs: stopwatch.ElapsedMilliseconds); 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 try
{ {
@ -281,6 +330,19 @@ public sealed class PersistedStoryIntelligenceRunner(
}); });
completedScenes++; 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) if (!validation.IsValid || validation.Warnings.Count > 0)
{ {
failedScenes += validation.Errors.Count > 0 ? 1 : 0; failedScenes += validation.Errors.Count > 0 ? 1 : 0;
@ -290,6 +352,18 @@ public sealed class PersistedStoryIntelligenceRunner(
{ {
failedScenes++; failedScenes++;
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException); 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); 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, stopwatch.ElapsedMilliseconds,
totals.EstimatedCostGBP, totals.EstimatedCostGBP,
totals.EstimatedCostUSD); 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) catch (StoryIntelligenceRunCancelledException)
{ {
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); 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) 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.", "Story Intelligence request timed out or was cancelled before the run completed.",
ex.ToString(), ex.ToString(),
stopwatch.ElapsedMilliseconds); stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
currentFailureStage,
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
} }
catch (StoryIntelligenceRunFailureException ex) catch (StoryIntelligenceRunFailureException ex)
{ {
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds); 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) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
@ -344,6 +454,14 @@ public sealed class PersistedStoryIntelligenceRunner(
ex.Message, ex.Message,
ex.ToString(), ex.ToString(),
stopwatch.ElapsedMilliseconds); 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<string>? 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<string> BuildDiscoveries(SceneIntelligenceScene? scene)
{
if (scene is null)
{
return [];
}
var discoveries = new List<string>();
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<string>()
.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<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>( private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
string completedPrompt, string completedPrompt,
string promptVersion, string promptVersion,

View File

@ -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<StoryIntelligenceHub> hub) : IStoryIntelligenceProgressNotifier
{
public Task PublishAsync(StoryIntelligenceRunProgressEvent progress)
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID))
.SendAsync("StoryIntelligenceRunProgressChanged", progress);
}

View File

@ -8,7 +8,7 @@
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedBookTitle</p> <p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1> <h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1>
<p>PlotDirector will read the approved chapter text, detect scene boundaries, and prepare scenes for your review before anything is created.</p> <p>PlotDirector can now analyse your manuscript to identify scenes and begin understanding your story.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -42,9 +42,9 @@
<p>Chapter text ready for analysis.</p> <p>Chapter text ready for analysis.</p>
</div> </div>
<div> <div>
<span>Creates</span> <span>Estimated time</span>
<strong>Scenes only</strong> <strong>@EstimateDuration(Model.ApprovedWordCount)</strong>
<p>Characters, locations, assets and relationships will not be imported yet.</p> <p>The analysis runs in the background, so you can leave this page and return later.</p>
</div> </div>
<div> <div>
<span>Review</span> <span>Review</span>
@ -53,6 +53,11 @@
</div> </div>
</div> </div>
<section class="onboarding-form-section">
<p>It will extract scene summaries, scene purpose, characters, locations, assets, relationships, timeline clues and story observations.</p>
<p>For this step, only scenes are created in PlotDirector. The other discoveries are prepared for later review stages.</p>
</section>
@if (!string.IsNullOrWhiteSpace(Model.Message)) @if (!string.IsNullOrWhiteSpace(Model.Message))
{ {
<section class="onboarding-form-section"> <section class="onboarding-form-section">
@ -75,3 +80,16 @@
} }
</div> </div>
</section> </section>
@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";
}
}

View File

@ -1,19 +1,20 @@
@model StoryIntelligenceProgressViewModel @model StoryIntelligenceProgressViewModel
@{ @{
ViewData["Title"] = "Review scenes"; ViewData["Title"] = "Review scenes";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
} }
<section class="onboarding-shell" aria-labelledby="story-progress-title"> <section class="onboarding-shell" aria-labelledby="story-progress-title">
<div class="onboarding-panel onboarding-review-panel"> <div class="onboarding-panel onboarding-review-panel"
@if (Model.HasActiveRuns) data-story-intelligence-onboarding
{ data-story-intelligence-batch-id="@Model.BatchID"
<meta http-equiv="refresh" content="10" /> data-story-intelligence-run-ids="@runIds"
} data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">@Model.BookTitle</p> <p class="eyebrow">@Model.BookTitle</p>
<h1 id="story-progress-title">Review scenes</h1> <h1 id="story-progress-title">Review scenes</h1>
<p>PlotDirector is analysing the approved chapters. When a chapter is ready, you can create its scenes in PlotDirector.</p> <p>PlotDirector is reading the approved chapters and turning them into scenes you can review before creating them.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -27,60 +28,90 @@
<div class="onboarding-scan-counts onboarding-review-counts"> <div class="onboarding-scan-counts onboarding-review-counts">
<div> <div>
<span>Chapters</span> <span>Overall progress</span>
<strong>@Model.CompletedChapterCount / @Model.ChapterCount</strong> <strong data-story-overall-percent>@progressPercent%</strong>
</div> </div>
<div> <div>
<span>Scenes detected</span> <span>Chapters completed</span>
<strong>@Model.TotalDetectedScenes</strong> <strong><span data-story-chapters-completed>@Model.CompletedChapterCount</span> / @Model.ChapterCount</strong>
</div> </div>
<div> <div>
<span>Scenes analysed</span> <span>Scenes analysed</span>
<strong>@Model.TotalCompletedScenes</strong> <strong><span data-story-scenes-completed>@Model.TotalCompletedScenes</span> / <span data-story-scenes-total>@Model.TotalDetectedScenes</span></strong>
</div> </div>
<div> <div>
<span>Failures</span> <span>Failures</span>
<strong>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong> <strong data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div> </div>
</div> </div>
<section class="onboarding-review-grid" aria-label="Chapter analysis progress"> <div class="progress mb-3" 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>
<section class="onboarding-review-grid" aria-label="Story Intelligence progress">
<div class="onboarding-review-section">
<h2>Reading now</h2>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Current chapter</span>
<strong data-story-current-chapter>@(CurrentChapter(Model)?.ChapterTitle ?? "Waiting")</strong>
</div>
<div>
<span>Current stage</span>
<strong data-story-current-stage>@(CurrentChapter(Model)?.CurrentStage ?? "Queued")</strong>
</div>
<div>
<span>Elapsed</span>
<strong data-story-elapsed>@Elapsed(Model)</strong>
</div>
<div>
<span>Estimated remaining</span>
<strong data-story-remaining>Calculating</strong>
</div>
</div>
<p data-story-current-message>@(CurrentChapter(Model)?.CurrentMessage ?? "Waiting for analysis to begin.")</p>
<p class="lead" data-story-latest-summary hidden></p>
<ul data-story-discovery-feed></ul>
</div>
<div class="onboarding-review-section"> <div class="onboarding-review-section">
<h2>Chapters</h2> <h2>Chapters</h2>
<div class="onboarding-review-chapters"> <div class="onboarding-review-chapters">
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) @foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{ {
<details class="onboarding-review-chapter" open> <details class="onboarding-review-chapter" open data-story-run-id="@chapter.RunID" data-story-chapter-title="@chapter.ChapterTitle">
<summary> <summary>
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span> <span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
<strong>@ChapterStatusLabel(chapter)</strong> <strong data-story-chapter-status-label>@ChapterStatusLabel(chapter)</strong>
</summary> </summary>
<div class="onboarding-scan-counts onboarding-review-counts"> <div class="onboarding-scan-counts onboarding-review-counts">
<div> <div>
<span>Status</span> <span>Status</span>
<strong>@chapter.Status</strong> <strong data-story-run-status>@chapter.Status</strong>
</div> </div>
<div> <div>
<span>Scenes</span> <span>Scenes</span>
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong> <strong><span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span> / <span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span></strong>
</div> </div>
<div> <div>
<span>Failed</span> <span>Failed</span>
<strong>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong> <strong data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
</div> </div>
<div> <div>
<span>Tokens</span> <span>Tokens</span>
<strong>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong> <strong data-story-run-tokens>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
</div> </div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage)) <p data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</p>
{
<p>@chapter.CurrentMessage</p>
}
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage)) @if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{ {
<p class="text-danger">@chapter.ErrorMessage</p> <p class="text-danger" data-story-run-error>@chapter.ErrorMessage</p>
}
else
{
<p class="text-danger" data-story-run-error hidden></p>
} }
@if (chapter.Warnings.Any()) @if (chapter.Warnings.Any())
{ {
@ -132,7 +163,6 @@
<input type="hidden" name="batchId" value="@Model.BatchID" /> <input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button> <button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form> </form>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Refresh</a>
<a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")" <a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")"
asp-action="StoryIntelligenceComplete" asp-action="StoryIntelligenceComplete"
asp-route-batchId="@Model.BatchID" asp-route-batchId="@Model.BatchID"
@ -142,6 +172,10 @@
</div> </div>
</section> </section>
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
}
@functions { @functions {
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter) private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
{ {
@ -167,4 +201,33 @@
return "Reviewing"; 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";
}
} }

View File

@ -1,15 +1,26 @@
(() => { (() => {
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")]; const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")]; 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) { if (roots.length === 0 || !window.signalR) {
return; return;
} }
const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null; 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 percent = field(state, "progressPercent", "ProgressPercent") ?? 0;
const status = field(state, "status", "Status") || "Pending"; const status = field(state, "status", "Status") || "Pending";
const stage = field(state, "currentStage", "CurrentStage") || status; 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"); const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId");
if (!jobId) { if (!jobId) {
return; return;
} }
roots legacyRoots
.filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase()) .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")) { if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`; 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() const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence") .withUrl("/hubs/story-intelligence")
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
connection.on("StoryIntelligenceProgressChanged", applyProgress); connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
connection.onreconnected(() => { 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() connection.start()
.then(() => Promise.all(jobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)))) .then(() => Promise.all([
.then((states) => states.filter(Boolean).forEach(applyProgress)) ...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(() => {}); .catch(() => {});
})(); })();