Phase 20H Implement the Story Intelligence framework.

This commit is contained in:
Nick Beckley 2026-07-04 12:59:22 +01:00
parent e3eb5d3528
commit b8541dbbc0
17 changed files with 1303 additions and 4 deletions

View File

@ -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")

View 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);
}
}

View 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);
}

View 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; }
}

View File

@ -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();
}

View File

@ -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()
};
}

View 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));
}

View 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

View File

@ -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

View File

@ -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; }

View File

@ -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>

View 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>

View 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">&check;</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>

View 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>
}

View File

@ -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");

View File

@ -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;
}

View 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(() => {});
})();