Phase 20H Implement the Story Intelligence framework.
This commit is contained in:
parent
e3eb5d3528
commit
b8541dbbc0
@ -8,7 +8,7 @@ namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("onboarding")]
|
||||
public sealed class OnboardingController(IOnboardingService onboarding) : Controller
|
||||
public sealed class OnboardingController(IOnboardingService onboarding, IStoryIntelligenceService storyIntelligence) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index()
|
||||
@ -31,6 +31,64 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence")]
|
||||
public async Task<IActionResult> StoryIntelligence()
|
||||
{
|
||||
var model = await storyIntelligence.GetOverviewAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/start")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> StartStoryIntelligence()
|
||||
{
|
||||
var job = await storyIntelligence.StartAsync();
|
||||
if (job is null)
|
||||
{
|
||||
TempData["ArchiveError"] = "Choose a project and book before starting Story Intelligence.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(StoryIntelligenceProgress), new { jobId = job.JobID });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/skip")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SkipStoryIntelligence()
|
||||
{
|
||||
await onboarding.MarkCompletedAsync();
|
||||
TempData["ArchiveMessage"] = "Story Intelligence skipped for now. You can return to it from your dashboard.";
|
||||
return RedirectToAction("Index", "Projects");
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/progress")]
|
||||
public async Task<IActionResult> StoryIntelligenceProgress(Guid jobId)
|
||||
{
|
||||
var model = await storyIntelligence.GetProgressAsync(jobId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/cancel")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CancelStoryIntelligence(Guid jobId)
|
||||
{
|
||||
var job = await storyIntelligence.CancelAsync(jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData["ArchiveMessage"] = "Story Intelligence setup was cancelled.";
|
||||
return RedirectToAction("Index", "Projects");
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/complete")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid jobId)
|
||||
{
|
||||
var model = await storyIntelligence.GetCompletionAsync(jobId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("scan-review")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
||||
|
||||
106
PlotLine/Data/StoryIntelligenceRepository.cs
Normal file
106
PlotLine/Data/StoryIntelligenceRepository.cs
Normal file
@ -0,0 +1,106 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface IStoryIntelligenceRepository
|
||||
{
|
||||
Task<StoryIntelligenceJob?> CreateWithConsentAsync(int userId, int onboardingStateId, int projectId, int bookId, string consentText);
|
||||
Task<StoryIntelligenceJob?> GetAsync(int userId, Guid jobId);
|
||||
Task<StoryIntelligenceJob?> GetLatestForUserAsync(int userId);
|
||||
Task<StoryIntelligenceJob?> ClaimNextPendingAsync();
|
||||
Task<StoryIntelligenceJob?> UpdateProgressAsync(Guid jobId, string status, int progressPercent, string currentStage, string currentMessage, string? estimatedRemaining);
|
||||
Task<StoryIntelligenceJob?> CompleteAsync(Guid jobId);
|
||||
Task<StoryIntelligenceJob?> CancelAsync(int userId, Guid jobId);
|
||||
Task<StoryIntelligenceJob?> FailAsync(Guid jobId, string errorMessage);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceRepository
|
||||
{
|
||||
public async Task<StoryIntelligenceJob?> CreateWithConsentAsync(int userId, int onboardingStateId, int projectId, int bookId, string consentText)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_CreateWithConsent",
|
||||
new
|
||||
{
|
||||
UserID = userId,
|
||||
UserOnboardingStateID = onboardingStateId,
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
ConsentText = consentText
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> GetAsync(int userId, Guid jobId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_Get",
|
||||
new { UserID = userId, JobID = jobId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> GetLatestForUserAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_GetLatestForUser",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> ClaimNextPendingAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_ClaimNextPending",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> UpdateProgressAsync(Guid jobId, string status, int progressPercent, string currentStage, string currentMessage, string? estimatedRemaining)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_UpdateProgress",
|
||||
new
|
||||
{
|
||||
JobID = jobId,
|
||||
Status = status,
|
||||
ProgressPercent = progressPercent,
|
||||
CurrentStage = currentStage,
|
||||
CurrentMessage = currentMessage,
|
||||
EstimatedRemaining = estimatedRemaining
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> CompleteAsync(Guid jobId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_Complete",
|
||||
new { JobID = jobId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> CancelAsync(int userId, Guid jobId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_Cancel",
|
||||
new { UserID = userId, JobID = jobId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> FailAsync(Guid jobId, string errorMessage)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceJob>(
|
||||
"dbo.StoryIntelligenceJob_Fail",
|
||||
new { JobID = jobId, ErrorMessage = errorMessage },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
36
PlotLine/Hubs/StoryIntelligenceHub.cs
Normal file
36
PlotLine/Hubs/StoryIntelligenceHub.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
|
||||
namespace PlotLine.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntelligence) : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (TryGetUserId(out var userId))
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJobProgress?> WatchStoryIntelligenceJob(Guid jobId)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
|
||||
return await storyIntelligence.GetProgressForUserAsync(userId, jobId);
|
||||
}
|
||||
|
||||
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
|
||||
|
||||
private int RequireUserId()
|
||||
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");
|
||||
|
||||
private bool TryGetUserId(out int userId)
|
||||
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
|
||||
}
|
||||
66
PlotLine/Models/StoryIntelligenceModels.cs
Normal file
66
PlotLine/Models/StoryIntelligenceModels.cs
Normal file
@ -0,0 +1,66 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public static class StoryIntelligenceJobStatuses
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Preparing = "Preparing";
|
||||
public const string ReadingScenes = "ReadingScenes";
|
||||
public const string AnalysingCharacters = "AnalysingCharacters";
|
||||
public const string AnalysingRelationships = "AnalysingRelationships";
|
||||
public const string AnalysingLocations = "AnalysingLocations";
|
||||
public const string AnalysingAssets = "AnalysingAssets";
|
||||
public const string AnalysingTimeline = "AnalysingTimeline";
|
||||
public const string Finalising = "Finalising";
|
||||
public const string Completed = "Completed";
|
||||
public const string Cancelled = "Cancelled";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceJob
|
||||
{
|
||||
public Guid JobID { get; init; }
|
||||
public int ConsentID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Status { get; init; } = StoryIntelligenceJobStatuses.Pending;
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
public DateTime? StartedUtc { get; init; }
|
||||
public DateTime? CompletedUtc { get; init; }
|
||||
public DateTime? CancelledUtc { get; init; }
|
||||
public int ProgressPercent { get; init; }
|
||||
public string CurrentStage { get; init; } = StoryIntelligenceJobStatuses.Pending;
|
||||
public string CurrentMessage { get; init; } = "Story Intelligence is queued.";
|
||||
public string? EstimatedRemaining { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
public bool IsActive => Status is StoryIntelligenceJobStatuses.Pending
|
||||
or StoryIntelligenceJobStatuses.Preparing
|
||||
or StoryIntelligenceJobStatuses.ReadingScenes
|
||||
or StoryIntelligenceJobStatuses.AnalysingCharacters
|
||||
or StoryIntelligenceJobStatuses.AnalysingRelationships
|
||||
or StoryIntelligenceJobStatuses.AnalysingLocations
|
||||
or StoryIntelligenceJobStatuses.AnalysingAssets
|
||||
or StoryIntelligenceJobStatuses.AnalysingTimeline
|
||||
or StoryIntelligenceJobStatuses.Finalising;
|
||||
public bool IsCompleted => string.Equals(Status, StoryIntelligenceJobStatuses.Completed, StringComparison.Ordinal);
|
||||
public bool IsCancelled => string.Equals(Status, StoryIntelligenceJobStatuses.Cancelled, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceJobProgress
|
||||
{
|
||||
public Guid JobID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string Status { get; init; } = StoryIntelligenceJobStatuses.Pending;
|
||||
public string CurrentStage { get; init; } = StoryIntelligenceJobStatuses.Pending;
|
||||
public string CurrentMessage { get; init; } = "Story Intelligence is queued.";
|
||||
public int ProgressPercent { get; init; }
|
||||
public string? EstimatedRemaining { get; init; }
|
||||
public string ElapsedTime { get; init; } = "0 min";
|
||||
public string? ErrorMessage { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
public bool IsCompleted { get; init; }
|
||||
}
|
||||
@ -133,6 +133,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||
@ -178,6 +179,8 @@ public class Program
|
||||
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
|
||||
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
|
||||
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||
@ -192,6 +195,7 @@ public class Program
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@ -221,6 +225,7 @@ public class Program
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}")
|
||||
.WithStaticAssets();
|
||||
app.MapHub<WordCompanionFollowHub>("/hubs/word-companion-follow");
|
||||
app.MapHub<StoryIntelligenceHub>("/hubs/story-intelligence");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
|
||||
@ -428,6 +428,7 @@ public sealed class ProjectService(
|
||||
IProjectActivityService activity,
|
||||
ISubscriptionService subscriptions,
|
||||
IOnboardingService onboarding,
|
||||
IStoryIntelligenceService storyIntelligence,
|
||||
ICurrentUserService currentUser,
|
||||
IHardDeleteFileCleanupService fileCleanup,
|
||||
ILogger<ProjectService> logger) : IProjectService
|
||||
@ -451,7 +452,8 @@ public sealed class ProjectService(
|
||||
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||
ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
||||
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow),
|
||||
OnboardingNudge = await onboarding.GetDashboardNudgeAsync()
|
||||
OnboardingNudge = await onboarding.GetDashboardNudgeAsync(),
|
||||
StoryIntelligence = await storyIntelligence.GetDashboardAsync()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
294
PlotLine/Services/StoryIntelligenceService.cs
Normal file
294
PlotLine/Services/StoryIntelligenceService.cs
Normal file
@ -0,0 +1,294 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Hubs;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryIntelligenceService
|
||||
{
|
||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId);
|
||||
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId);
|
||||
Task<StoryIntelligenceJob?> StartAsync();
|
||||
Task<StoryIntelligenceJob?> CancelAsync(Guid jobId);
|
||||
Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync();
|
||||
Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId);
|
||||
}
|
||||
|
||||
public interface IStoryIntelligenceProvider
|
||||
{
|
||||
Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceProviderProgress
|
||||
{
|
||||
public string Status { get; init; } = StoryIntelligenceJobStatuses.Preparing;
|
||||
public string Stage { get; init; } = StoryIntelligenceJobStatuses.Preparing;
|
||||
public string Message { get; init; } = "Preparing manuscript...";
|
||||
public int Percent { get; init; }
|
||||
public string? EstimatedRemaining { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider
|
||||
{
|
||||
public async Task ProcessAsync(StoryIntelligenceJob job, Func<StoryIntelligenceProviderProgress, Task> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var stages = new[]
|
||||
{
|
||||
new StoryIntelligenceProviderProgress
|
||||
{
|
||||
Percent = 20,
|
||||
Message = "Building analysis pipeline...",
|
||||
EstimatedRemaining = "Calculating"
|
||||
},
|
||||
new StoryIntelligenceProviderProgress
|
||||
{
|
||||
Percent = 55,
|
||||
Message = "Preparing scene batches...",
|
||||
EstimatedRemaining = "Calculating"
|
||||
},
|
||||
new StoryIntelligenceProviderProgress
|
||||
{
|
||||
Percent = 85,
|
||||
Message = "Initialising Story Intelligence...",
|
||||
EstimatedRemaining = "Calculating"
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var stage in stages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await progress(stage);
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceService(
|
||||
IStoryIntelligenceRepository repository,
|
||||
IOnboardingRepository onboarding,
|
||||
IProjectRepository projects,
|
||||
IBookRepository books,
|
||||
ICurrentUserService currentUser) : IStoryIntelligenceService
|
||||
{
|
||||
private const string ConsentText = "I consent to PlotDirector preparing the Story Intelligence analysis framework for this manuscript. I understand future AI analysis is optional and will use the OpenAI API.";
|
||||
|
||||
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
|
||||
{
|
||||
var state = await onboarding.GetOrCreateAsync(RequireUserId());
|
||||
var latest = await repository.GetLatestForUserAsync(state.UserID);
|
||||
return new StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
ProjectID = state.ProjectID,
|
||||
BookID = state.BookID,
|
||||
SelectedProjectName = state.ProjectID.HasValue ? (await projects.GetForUserAsync(state.ProjectID.Value, state.UserID))?.ProjectName : null,
|
||||
SelectedBookTitle = state.BookID.HasValue ? BuildBookTitle(await books.GetAsync(state.BookID.Value)) : null,
|
||||
ExistingJob = latest is null ? null : ToProgress(latest)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid jobId)
|
||||
{
|
||||
var job = await repository.GetAsync(RequireUserId(), jobId);
|
||||
return job is null
|
||||
? null
|
||||
: new StoryIntelligenceProgressViewModel
|
||||
{
|
||||
Job = ToProgress(job),
|
||||
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
|
||||
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid jobId)
|
||||
{
|
||||
var job = await repository.GetAsync(RequireUserId(), jobId);
|
||||
if (job is null || !job.IsCompleted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new StoryIntelligenceCompletionViewModel
|
||||
{
|
||||
Job = ToProgress(job),
|
||||
ProjectID = job.ProjectID,
|
||||
BookID = job.BookID,
|
||||
ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName,
|
||||
BookTitle = BuildBookTitle(await books.GetAsync(job.BookID))
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> StartAsync()
|
||||
{
|
||||
var state = await onboarding.GetOrCreateAsync(RequireUserId());
|
||||
if (!state.ProjectID.HasValue || !state.BookID.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await repository.CreateWithConsentAsync(
|
||||
state.UserID,
|
||||
state.UserOnboardingStateID,
|
||||
state.ProjectID.Value,
|
||||
state.BookID.Value,
|
||||
ConsentText);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJob?> CancelAsync(Guid jobId)
|
||||
=> await repository.CancelAsync(RequireUserId(), jobId);
|
||||
|
||||
public async Task<StoryIntelligenceDashboardViewModel> GetDashboardAsync()
|
||||
{
|
||||
if (!currentUser.UserId.HasValue)
|
||||
{
|
||||
return new StoryIntelligenceDashboardViewModel();
|
||||
}
|
||||
|
||||
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
||||
var latest = await repository.GetLatestForUserAsync(currentUser.UserId.Value);
|
||||
if (latest is not null)
|
||||
{
|
||||
return new StoryIntelligenceDashboardViewModel
|
||||
{
|
||||
ShouldShow = true,
|
||||
Job = ToProgress(latest),
|
||||
Title = latest.IsCompleted ? "Story Intelligence ready" : latest.IsActive ? "Story Intelligence preparing" : "Story Intelligence available",
|
||||
Description = latest.IsCompleted
|
||||
? "The analysis framework is prepared for your manuscript."
|
||||
: latest.IsActive
|
||||
? latest.CurrentMessage
|
||||
: "You can prepare Story Intelligence for your built manuscript.",
|
||||
ButtonText = latest.IsCompleted ? "View completion" : latest.IsActive ? "View progress" : "Set up Story Intelligence"
|
||||
};
|
||||
}
|
||||
|
||||
return state?.ProjectID.HasValue == true && state.BookID.HasValue
|
||||
? new StoryIntelligenceDashboardViewModel
|
||||
{
|
||||
ShouldShow = true,
|
||||
Title = "Story Intelligence available",
|
||||
Description = "Prepare the optional analysis framework for your manuscript.",
|
||||
ButtonText = "Set up Story Intelligence"
|
||||
}
|
||||
: new StoryIntelligenceDashboardViewModel();
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceJobProgress?> GetProgressForUserAsync(int userId, Guid jobId)
|
||||
{
|
||||
var job = await repository.GetAsync(userId, jobId);
|
||||
return job is null ? null : ToProgress(job);
|
||||
}
|
||||
|
||||
public static StoryIntelligenceJobProgress ToProgress(StoryIntelligenceJob job)
|
||||
{
|
||||
var elapsedFrom = job.StartedUtc ?? job.CreatedUtc;
|
||||
return new StoryIntelligenceJobProgress
|
||||
{
|
||||
JobID = job.JobID,
|
||||
UserID = job.UserID,
|
||||
ProjectID = job.ProjectID,
|
||||
BookID = job.BookID,
|
||||
Status = job.Status,
|
||||
CurrentStage = job.CurrentStage,
|
||||
CurrentMessage = job.CurrentMessage,
|
||||
ProgressPercent = job.ProgressPercent,
|
||||
EstimatedRemaining = job.EstimatedRemaining,
|
||||
ErrorMessage = job.ErrorMessage,
|
||||
UpdatedUtc = job.UpdatedUtc,
|
||||
IsActive = job.IsActive,
|
||||
IsCompleted = job.IsCompleted,
|
||||
ElapsedTime = FormatElapsed(DateTime.UtcNow - elapsedFrom)
|
||||
};
|
||||
}
|
||||
|
||||
private int RequireUserId()
|
||||
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in to use Story Intelligence.");
|
||||
|
||||
private static string BuildBookTitle(Book? book)
|
||||
=> book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}";
|
||||
|
||||
private static string FormatElapsed(TimeSpan elapsed)
|
||||
=> elapsed.TotalMinutes < 1
|
||||
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"
|
||||
: $"{(int)elapsed.TotalMinutes} min {elapsed.Seconds:D2} sec";
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHubContext<StoryIntelligenceHub> hub,
|
||||
ILogger<StoryIntelligenceWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await ProcessNextAsync(stoppingToken);
|
||||
await timer.WaitForNextTickAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessNextAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceRepository>();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProvider>();
|
||||
var job = await repository.ClaimNextPendingAsync();
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await NotifyAsync(job);
|
||||
try
|
||||
{
|
||||
await provider.ProcessAsync(job, async update =>
|
||||
{
|
||||
var updated = await repository.UpdateProgressAsync(
|
||||
job.JobID,
|
||||
update.Status,
|
||||
update.Percent,
|
||||
update.Stage,
|
||||
update.Message,
|
||||
update.EstimatedRemaining);
|
||||
if (updated is not null)
|
||||
{
|
||||
await NotifyAsync(updated);
|
||||
if (updated.IsCancelled)
|
||||
{
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
}, stoppingToken);
|
||||
|
||||
var completed = await repository.CompleteAsync(job.JobID);
|
||||
if (completed is not null)
|
||||
{
|
||||
await NotifyAsync(completed);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var cancelled = await repository.CancelAsync(job.UserID, job.JobID);
|
||||
if (cancelled is not null)
|
||||
{
|
||||
await NotifyAsync(cancelled);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Story Intelligence job {JobID} failed.", job.JobID);
|
||||
var failed = await repository.FailAsync(job.JobID, ex.Message);
|
||||
if (failed is not null)
|
||||
{
|
||||
await NotifyAsync(failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task NotifyAsync(StoryIntelligenceJob job)
|
||||
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(job.UserID))
|
||||
.SendAsync("StoryIntelligenceProgressChanged", StoryIntelligenceService.ToProgress(job));
|
||||
}
|
||||
304
PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql
Normal file
304
PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql
Normal file
@ -0,0 +1,304 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.StoryIntelligenceConsents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.StoryIntelligenceConsents
|
||||
(
|
||||
StoryIntelligenceConsentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceConsents PRIMARY KEY,
|
||||
UserID int NOT NULL,
|
||||
UserOnboardingStateID int NOT NULL,
|
||||
ProjectID int NOT NULL,
|
||||
BookID int NOT NULL,
|
||||
ConsentedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceConsents_ConsentedUtc DEFAULT SYSUTCDATETIME(),
|
||||
ConsentVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryIntelligenceConsents_ConsentVersion DEFAULT N'Phase20H',
|
||||
ConsentText nvarchar(1000) NOT NULL,
|
||||
CONSTRAINT FK_StoryIntelligenceConsents_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT FK_StoryIntelligenceConsents_UserOnboardingState FOREIGN KEY (UserOnboardingStateID) REFERENCES dbo.UserOnboardingState(UserOnboardingStateID),
|
||||
CONSTRAINT FK_StoryIntelligenceConsents_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||
CONSTRAINT FK_StoryIntelligenceConsents_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.StoryIntelligenceJobs', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.StoryIntelligenceJobs
|
||||
(
|
||||
StoryIntelligenceJobID uniqueidentifier NOT NULL CONSTRAINT PK_StoryIntelligenceJobs PRIMARY KEY,
|
||||
StoryIntelligenceConsentID int NOT NULL,
|
||||
UserID int NOT NULL,
|
||||
ProjectID int NOT NULL,
|
||||
BookID int NOT NULL,
|
||||
Status nvarchar(40) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_Status DEFAULT N'Pending',
|
||||
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
StartedUtc datetime2 NULL,
|
||||
CompletedUtc datetime2 NULL,
|
||||
CancelledUtc datetime2 NULL,
|
||||
ProgressPercent int NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_ProgressPercent DEFAULT 0,
|
||||
CurrentStage nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CurrentStage DEFAULT N'Pending',
|
||||
CurrentMessage nvarchar(400) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CurrentMessage DEFAULT N'Story Intelligence is queued.',
|
||||
EstimatedRemaining nvarchar(80) NULL,
|
||||
ErrorMessage nvarchar(1000) NULL,
|
||||
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_UpdatedUtc DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT FK_StoryIntelligenceJobs_Consent FOREIGN KEY (StoryIntelligenceConsentID) REFERENCES dbo.StoryIntelligenceConsents(StoryIntelligenceConsentID),
|
||||
CONSTRAINT FK_StoryIntelligenceJobs_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
|
||||
CONSTRAINT FK_StoryIntelligenceJobs_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
|
||||
CONSTRAINT FK_StoryIntelligenceJobs_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID),
|
||||
CONSTRAINT CK_StoryIntelligenceJobs_Status CHECK (Status IN
|
||||
(
|
||||
N'Pending', N'Preparing', N'ReadingScenes', N'AnalysingCharacters', N'AnalysingRelationships',
|
||||
N'AnalysingLocations', N'AnalysingAssets', N'AnalysingTimeline', N'Finalising',
|
||||
N'Completed', N'Cancelled', N'Failed'
|
||||
)),
|
||||
CONSTRAINT CK_StoryIntelligenceJobs_ProgressPercent CHECK (ProgressPercent >= 0 AND ProgressPercent <= 100)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceJobs_User_Status' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceJobs'))
|
||||
CREATE INDEX IX_StoryIntelligenceJobs_User_Status ON dbo.StoryIntelligenceJobs(UserID, Status, CreatedUtc DESC);
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceJobs_Pending' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceJobs'))
|
||||
CREATE INDEX IX_StoryIntelligenceJobs_Pending ON dbo.StoryIntelligenceJobs(Status, CreatedUtc) INCLUDE (StoryIntelligenceJobID);
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Get
|
||||
@UserID int,
|
||||
@JobID uniqueidentifier
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND UserID = @UserID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_CreateWithConsent
|
||||
@UserID int,
|
||||
@UserOnboardingStateID int,
|
||||
@ProjectID int,
|
||||
@BookID int,
|
||||
@ConsentText nvarchar(1000)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.UserOnboardingState uos
|
||||
INNER JOIN dbo.Books b ON b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0
|
||||
INNER JOIN dbo.Projects p ON p.ProjectID = @ProjectID AND p.IsArchived = 0
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID AND pua.UserID = @UserID AND pua.IsActive = 1
|
||||
WHERE uos.UserOnboardingStateID = @UserOnboardingStateID
|
||||
AND uos.UserID = @UserID
|
||||
)
|
||||
RETURN;
|
||||
|
||||
DECLARE @ExistingActiveJobID uniqueidentifier;
|
||||
SELECT TOP (1) @ExistingActiveJobID = StoryIntelligenceJobID
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE UserID = @UserID
|
||||
AND ProjectID = @ProjectID
|
||||
AND BookID = @BookID
|
||||
AND Status IN (N'Pending', N'Preparing', N'ReadingScenes', N'AnalysingCharacters', N'AnalysingRelationships',
|
||||
N'AnalysingLocations', N'AnalysingAssets', N'AnalysingTimeline', N'Finalising')
|
||||
ORDER BY CreatedUtc DESC;
|
||||
|
||||
IF @ExistingActiveJobID IS NOT NULL
|
||||
BEGIN
|
||||
EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @ExistingActiveJobID;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @ConsentID int;
|
||||
DECLARE @JobID uniqueidentifier = NEWID();
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
INSERT dbo.StoryIntelligenceConsents (UserID, UserOnboardingStateID, ProjectID, BookID, ConsentText)
|
||||
VALUES (@UserID, @UserOnboardingStateID, @ProjectID, @BookID, @ConsentText);
|
||||
SET @ConsentID = CAST(SCOPE_IDENTITY() AS int);
|
||||
|
||||
INSERT dbo.StoryIntelligenceJobs
|
||||
(
|
||||
StoryIntelligenceJobID, StoryIntelligenceConsentID, UserID, ProjectID, BookID,
|
||||
Status, ProgressPercent, CurrentStage, CurrentMessage, EstimatedRemaining
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@JobID, @ConsentID, @UserID, @ProjectID, @BookID,
|
||||
N'Pending', 0, N'Pending', N'Story Intelligence is queued.', N'Calculating'
|
||||
);
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_GetLatestForUser
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP (1) StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE UserID = @UserID
|
||||
ORDER BY CreatedUtc DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_ClaimNextPending
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @JobID uniqueidentifier;
|
||||
SELECT TOP (1) @JobID = StoryIntelligenceJobID
|
||||
FROM dbo.StoryIntelligenceJobs WITH (UPDLOCK, READPAST)
|
||||
WHERE Status = N'Pending'
|
||||
ORDER BY CreatedUtc;
|
||||
|
||||
IF @JobID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0) StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceJobs
|
||||
SET Status = N'Preparing',
|
||||
StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()),
|
||||
ProgressPercent = 5,
|
||||
CurrentStage = N'Preparing',
|
||||
CurrentMessage = N'Preparing manuscript...',
|
||||
EstimatedRemaining = N'Calculating',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND Status = N'Pending';
|
||||
|
||||
SELECT j.StoryIntelligenceJobID AS JobID, j.StoryIntelligenceConsentID AS ConsentID, j.UserID, j.ProjectID, j.BookID,
|
||||
j.Status, j.CreatedUtc, j.StartedUtc, j.CompletedUtc, j.CancelledUtc, j.ProgressPercent, j.CurrentStage,
|
||||
j.CurrentMessage, j.EstimatedRemaining, j.ErrorMessage, j.UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs j
|
||||
WHERE j.StoryIntelligenceJobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_UpdateProgress
|
||||
@JobID uniqueidentifier,
|
||||
@Status nvarchar(40),
|
||||
@ProgressPercent int,
|
||||
@CurrentStage nvarchar(80),
|
||||
@CurrentMessage nvarchar(400),
|
||||
@EstimatedRemaining nvarchar(80) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceJobs
|
||||
SET Status = @Status,
|
||||
ProgressPercent = @ProgressPercent,
|
||||
CurrentStage = @CurrentStage,
|
||||
CurrentMessage = @CurrentMessage,
|
||||
EstimatedRemaining = @EstimatedRemaining,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND Status NOT IN (N'Completed', N'Cancelled', N'Failed');
|
||||
|
||||
SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE StoryIntelligenceJobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Complete
|
||||
@JobID uniqueidentifier
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceJobs
|
||||
SET Status = N'Completed',
|
||||
ProgressPercent = 100,
|
||||
CurrentStage = N'Completed',
|
||||
CurrentMessage = N'Story Intelligence is ready.',
|
||||
EstimatedRemaining = N'Complete',
|
||||
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND Status NOT IN (N'Completed', N'Cancelled', N'Failed');
|
||||
|
||||
SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE StoryIntelligenceJobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Cancel
|
||||
@UserID int,
|
||||
@JobID uniqueidentifier
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceJobs
|
||||
SET Status = N'Cancelled',
|
||||
CurrentStage = N'Cancelled',
|
||||
CurrentMessage = N'Story Intelligence setup was cancelled.',
|
||||
EstimatedRemaining = NULL,
|
||||
CancelledUtc = COALESCE(CancelledUtc, SYSUTCDATETIME()),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND UserID = @UserID
|
||||
AND Status NOT IN (N'Completed', N'Cancelled', N'Failed');
|
||||
|
||||
EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Fail
|
||||
@JobID uniqueidentifier,
|
||||
@ErrorMessage nvarchar(1000)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceJobs
|
||||
SET Status = N'Failed',
|
||||
CurrentStage = N'Failed',
|
||||
CurrentMessage = N'Story Intelligence setup could not finish.',
|
||||
ErrorMessage = @ErrorMessage,
|
||||
EstimatedRemaining = NULL,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceJobID = @JobID
|
||||
AND Status NOT IN (N'Completed', N'Cancelled');
|
||||
|
||||
SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID,
|
||||
Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage,
|
||||
CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceJobs
|
||||
WHERE StoryIntelligenceJobID = @JobID;
|
||||
END;
|
||||
GO
|
||||
@ -27,6 +27,7 @@ public sealed class ProjectIndexViewModel
|
||||
public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
|
||||
public TrialVisibilityViewModel? Trial { get; init; }
|
||||
public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new();
|
||||
public StoryIntelligenceDashboardViewModel StoryIntelligence { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProjectIndexCardViewModel
|
||||
|
||||
@ -104,6 +104,41 @@ public sealed class OnboardingNudgeViewModel
|
||||
public bool CompanionConnected { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string? SelectedProjectName { get; init; }
|
||||
public string? SelectedBookTitle { get; init; }
|
||||
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
||||
public bool CanStart => ProjectID.HasValue && BookID.HasValue && ExistingJob?.IsActive != true;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceProgressViewModel
|
||||
{
|
||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceCompletionViewModel
|
||||
{
|
||||
public StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
public int ProjectID { get; init; }
|
||||
public int BookID { get; init; }
|
||||
public string? ProjectName { get; init; }
|
||||
public string? BookTitle { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceDashboardViewModel
|
||||
{
|
||||
public bool ShouldShow { get; init; }
|
||||
public string Title { get; init; } = "Story Intelligence available";
|
||||
public string Description { get; init; } = "Prepare the optional analysis framework for your manuscript.";
|
||||
public string ButtonText { get; init; } = "Set up Story Intelligence";
|
||||
public StoryIntelligenceJobProgress? Job { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CompanionPresenceViewModel
|
||||
{
|
||||
public bool IsConnected { get; init; }
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Continue to Story Intelligence</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="StoryIntelligence">Continue to Story Intelligence</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
70
PlotLine/Views/Onboarding/StoryIntelligence.cshtml
Normal file
70
PlotLine/Views/Onboarding/StoryIntelligence.cshtml
Normal file
@ -0,0 +1,70 @@
|
||||
@model StoryIntelligenceOverviewViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
||||
<div class="onboarding-panel">
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h1 id="story-intelligence-title">PlotDirector can now analyse your manuscript to automatically build much of your project.</h1>
|
||||
<p>This step is optional. In this phase, PlotDirector only prepares the analysis framework; no AI analysis will run yet.</p>
|
||||
</div>
|
||||
|
||||
@if (Model.ExistingJob?.IsActive == true)
|
||||
{
|
||||
<section class="onboarding-story-card">
|
||||
<h2>Story Intelligence is already preparing</h2>
|
||||
<p>@Model.ExistingJob.CurrentMessage</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-jobId="@Model.ExistingJob.JobID">View progress</a>
|
||||
</section>
|
||||
}
|
||||
else if (Model.ExistingJob?.IsCompleted == true)
|
||||
{
|
||||
<section class="onboarding-story-card">
|
||||
<h2>Story Intelligence is ready</h2>
|
||||
<p>The framework has already been prepared for this manuscript.</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-jobId="@Model.ExistingJob.JobID">View completion</a>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="onboarding-story-facts">
|
||||
<div>
|
||||
<span>Optional</span>
|
||||
<strong>You stay in control</strong>
|
||||
<p>You can skip this now and continue using the project structure you already built.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Uses AI later</span>
|
||||
<strong>Explicit consent required</strong>
|
||||
<p>Future manuscript analysis will only run after you choose to start it.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Privacy</span>
|
||||
<strong>OpenAI API content is not used to train AI models</strong>
|
||||
<p>Manuscript content is processed securely when AI analysis is enabled in a later phase.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Estimate</span>
|
||||
<strong>About 5-15 minutes</strong>
|
||||
<p>Credit use will be shown here before charging is enabled. Placeholder: included setup credits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-form-section">
|
||||
<h2>Consent</h2>
|
||||
<p>By choosing Analyse my manuscript, you consent to PlotDirector preparing the Story Intelligence job for @Model.SelectedBookTitle in @Model.SelectedProjectName. This phase does not call OpenAI or analyse manuscript content.</p>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<form asp-action="SkipStoryIntelligence" method="post">
|
||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||
</form>
|
||||
<form asp-action="StartStoryIntelligence" method="post">
|
||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse my manuscript</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
47
PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml
Normal file
47
PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml
Normal file
@ -0,0 +1,47 @@
|
||||
@model StoryIntelligenceCompletionViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence ready";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-complete-title">
|
||||
<div class="onboarding-panel onboarding-review-panel">
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">@Model.BookTitle</p>
|
||||
<div class="onboarding-success-heading">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="story-complete-title">Story Intelligence is ready.</h1>
|
||||
</div>
|
||||
<p>The analysis engine is prepared. In the next phase PlotDirector will begin analysing your manuscript.</p>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-complete-next">
|
||||
<h2>Temporary completion page</h2>
|
||||
<p>This page confirms the framework job, consent, background processing and progress plumbing are working. Later phases will replace it with real analysis results.</p>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>@Model.Job.Status</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Progress</span>
|
||||
<strong>@Model.Job.ProgressPercent%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Elapsed</span>
|
||||
<strong>@Model.Job.ElapsedTime</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Stage</span>
|
||||
<strong>@Model.Job.CurrentStage</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
60
PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml
Normal file
60
PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml
Normal file
@ -0,0 +1,60 @@
|
||||
@model StoryIntelligenceProgressViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Preparing Story Intelligence";
|
||||
}
|
||||
|
||||
<section class="onboarding-shell" aria-labelledby="story-progress-title">
|
||||
<div class="onboarding-panel"
|
||||
data-story-intelligence-progress
|
||||
data-story-intelligence-job-id="@Model.Job.JobID">
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">@Model.BookTitle</p>
|
||||
<h1 id="story-progress-title">Preparing Story Intelligence</h1>
|
||||
<p>PlotDirector is preparing the analysis engine. This phase does not run AI analysis or generate story suggestions.</p>
|
||||
</div>
|
||||
|
||||
<section class="onboarding-story-progress" aria-label="Story Intelligence progress">
|
||||
<div class="onboarding-scan-status">
|
||||
<span data-story-intelligence-message>@Model.Job.CurrentMessage</span>
|
||||
<strong data-story-intelligence-percent>@Model.Job.ProgressPercent%</strong>
|
||||
</div>
|
||||
<div class="onboarding-scan-progress" aria-hidden="true">
|
||||
<span data-story-intelligence-progress-bar style="width:@Model.Job.ProgressPercent%"></span>
|
||||
</div>
|
||||
<div class="onboarding-scan-counts">
|
||||
<div>
|
||||
<span>Stage</span>
|
||||
<strong data-story-intelligence-stage>@Model.Job.CurrentStage</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong data-story-intelligence-status>@Model.Job.Status</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Elapsed</span>
|
||||
<strong data-story-intelligence-elapsed>@Model.Job.ElapsedTime</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Remaining</span>
|
||||
<strong data-story-intelligence-remaining>@(Model.Job.EstimatedRemaining ?? "Calculating")</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
<form asp-action="CancelStoryIntelligence" method="post">
|
||||
<input type="hidden" name="jobId" value="@Model.Job.JobID" />
|
||||
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.Job.IsActive)">Cancel</button>
|
||||
</form>
|
||||
<a class="btn btn-primary @(Model.Job.IsCompleted ? string.Empty : "disabled")"
|
||||
data-story-intelligence-complete-link
|
||||
asp-action="StoryIntelligenceComplete"
|
||||
asp-route-jobId="@Model.Job.JobID"
|
||||
aria-disabled="@(!Model.Job.IsCompleted)">Continue</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
|
||||
}
|
||||
@ -47,6 +47,42 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (Model.StoryIntelligence.ShouldShow)
|
||||
{
|
||||
<section class="story-intelligence-dashboard-card"
|
||||
data-story-intelligence-dashboard
|
||||
data-story-intelligence-job-id="@Model.StoryIntelligence.Job?.JobID"
|
||||
aria-label="Story Intelligence">
|
||||
<div>
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h2>@Model.StoryIntelligence.Title</h2>
|
||||
<p data-story-intelligence-message>@Model.StoryIntelligence.Description</p>
|
||||
@if (Model.StoryIntelligence.Job is not null)
|
||||
{
|
||||
<div class="story-intelligence-dashboard-progress">
|
||||
<span data-story-intelligence-status>@Model.StoryIntelligence.Job.Status</span>
|
||||
<strong data-story-intelligence-percent>@Model.StoryIntelligence.Job.ProgressPercent%</strong>
|
||||
<div class="onboarding-scan-progress" aria-hidden="true">
|
||||
<span data-story-intelligence-progress-bar style="width:@Model.StoryIntelligence.Job.ProgressPercent%"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.StoryIntelligence.Job?.IsActive == true)
|
||||
{
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligenceProgress" asp-route-jobId="@Model.StoryIntelligence.Job.JobID">@Model.StoryIntelligence.ButtonText</a>
|
||||
}
|
||||
else if (Model.StoryIntelligence.Job?.IsCompleted == true)
|
||||
{
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligenceComplete" asp-route-jobId="@Model.StoryIntelligence.Job.JobID">@Model.StoryIntelligence.ButtonText</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">@Model.StoryIntelligence.ButtonText</a>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (TempData["ArchiveMessage"] is string archiveMessage)
|
||||
{
|
||||
<div class="alert alert-success">@archiveMessage</div>
|
||||
@ -309,6 +345,7 @@ else
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const modalElement = document.getElementById("hardDeleteProjectModal");
|
||||
|
||||
@ -650,6 +650,101 @@
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.onboarding-story-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-story-facts div,
|
||||
.onboarding-story-card,
|
||||
.story-intelligence-dashboard-card {
|
||||
border: 1px solid rgba(31, 42, 68, .12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .62);
|
||||
}
|
||||
|
||||
.onboarding-story-facts div {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-story-facts span {
|
||||
color: #2f6f63;
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.onboarding-story-facts strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.onboarding-story-facts p,
|
||||
.onboarding-story-card p {
|
||||
margin: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.onboarding-story-card {
|
||||
display: grid;
|
||||
gap: .7rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.onboarding-story-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.onboarding-story-progress {
|
||||
display: grid;
|
||||
gap: .9rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(47, 111, 99, .18);
|
||||
border-radius: 8px;
|
||||
background: rgba(47, 111, 99, .07);
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1.15rem 1.25rem;
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-card h2 {
|
||||
margin: 0 0 .35rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-card p {
|
||||
margin: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-progress {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: .45rem .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-progress .onboarding-scan-progress {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.story-intelligence-dashboard-progress span {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-dashboard-nudge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -744,12 +839,17 @@
|
||||
.onboarding-dashboard-nudge,
|
||||
.onboarding-companion-card,
|
||||
.onboarding-review-grid,
|
||||
.onboarding-summary div {
|
||||
.onboarding-summary div,
|
||||
.story-intelligence-dashboard-card {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.onboarding-story-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.onboarding-companion-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
78
PlotLine/wwwroot/js/story-intelligence-progress.js
Normal file
78
PlotLine/wwwroot/js/story-intelligence-progress.js
Normal file
@ -0,0 +1,78 @@
|
||||
(() => {
|
||||
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
|
||||
const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")];
|
||||
const roots = [...progressRoots, ...dashboardRoots];
|
||||
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 updateRoot = (root, state) => {
|
||||
const percent = field(state, "progressPercent", "ProgressPercent") ?? 0;
|
||||
const status = field(state, "status", "Status") || "Pending";
|
||||
const stage = field(state, "currentStage", "CurrentStage") || status;
|
||||
const message = field(state, "currentMessage", "CurrentMessage") || "Story Intelligence is preparing.";
|
||||
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
|
||||
const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating";
|
||||
|
||||
root.querySelectorAll("[data-story-intelligence-message]").forEach((node) => {
|
||||
node.textContent = message;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-percent]").forEach((node) => {
|
||||
node.textContent = `${percent}%`;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-progress-bar]").forEach((node) => {
|
||||
node.style.width = `${Math.max(0, Math.min(100, Number.parseInt(percent, 10) || 0))}%`;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-stage]").forEach((node) => {
|
||||
node.textContent = stage;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-status]").forEach((node) => {
|
||||
node.textContent = status;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-elapsed]").forEach((node) => {
|
||||
node.textContent = elapsed;
|
||||
});
|
||||
root.querySelectorAll("[data-story-intelligence-remaining]").forEach((node) => {
|
||||
node.textContent = remaining;
|
||||
});
|
||||
|
||||
const completed = !!field(state, "isCompleted", "IsCompleted");
|
||||
root.querySelectorAll("[data-story-intelligence-complete-link]").forEach((node) => {
|
||||
node.classList.toggle("disabled", !completed);
|
||||
node.setAttribute("aria-disabled", String(!completed));
|
||||
});
|
||||
};
|
||||
|
||||
const applyProgress = (state) => {
|
||||
const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId");
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
roots
|
||||
.filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase())
|
||||
.forEach((root) => updateRoot(root, state));
|
||||
|
||||
if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
|
||||
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/story-intelligence")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on("StoryIntelligenceProgressChanged", applyProgress);
|
||||
connection.onreconnected(() => {
|
||||
jobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyProgress).catch(() => {}));
|
||||
});
|
||||
|
||||
connection.start()
|
||||
.then(() => Promise.all(jobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId))))
|
||||
.then((states) => states.filter(Boolean).forEach(applyProgress))
|
||||
.catch(() => {});
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user