Phase 20AA – Connect Onboarding Wizard To Story Intelligence Scene Import

This commit is contained in:
Nick Beckley 2026-07-05 22:38:59 +01:00
parent aefa9aaadc
commit 4b7af17b42
16 changed files with 758 additions and 155 deletions

View File

@ -8,7 +8,7 @@ namespace PlotLine.Controllers;
[Authorize]
[Route("onboarding")]
public sealed class OnboardingController(IOnboardingService onboarding, IStoryIntelligenceService storyIntelligence) : Controller
public sealed class OnboardingController(IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index()
@ -45,11 +45,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
var job = await storyIntelligence.StartAsync();
if (job is null)
{
TempData["ArchiveError"] = "Choose a project and book before starting Story Intelligence.";
return RedirectToAction(nameof(Index));
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
return RedirectToAction(nameof(StoryIntelligence));
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { jobId = job.JobID });
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
}
[HttpPost("story-intelligence/skip")]
@ -62,30 +62,50 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
}
[HttpGet("story-intelligence/progress")]
public async Task<IActionResult> StoryIntelligenceProgress(Guid jobId)
public async Task<IActionResult> StoryIntelligenceProgress(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(jobId);
var model = await storyIntelligence.GetProgressAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpPost("story-intelligence/cancel")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CancelStoryIntelligence(Guid jobId)
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
{
var job = await storyIntelligence.CancelAsync(jobId);
if (job is null)
var progress = await storyIntelligence.CancelAsync(batchId);
if (progress is null)
{
return NotFound();
}
TempData["ArchiveMessage"] = "Story Intelligence setup was cancelled.";
return RedirectToAction("Index", "Projects");
TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested.";
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
[HttpPost("story-intelligence/commit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CommitStoryIntelligence(Guid batchId, int runId)
{
var (progress, result) = await storyIntelligence.CommitAsync(batchId, runId);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (result.Success && progress.AllCommitted)
{
TempData["WriterWorkspaceMessage"] = $"{progress.CommittedChapterCount} chapter(s) and {progress.Chapters.Sum(chapter => chapter.ScenesCreated)} scene(s) created from your manuscript.";
return RedirectToAction("Index", "Writer", new { projectId = progress.ProjectID });
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid jobId)
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{
var model = await storyIntelligence.GetCompletionAsync(jobId);
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}
@ -103,7 +123,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IStoryIn
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Review saved. Next: import into PlotDirector."
? "Review saved. Next: prepare chapters and analyse manuscript."
: "Review choices saved.";
return readyToImport
? RedirectToAction(nameof(Index))

View File

@ -75,6 +75,7 @@ public sealed class ManuscriptScanChapterPreview
public int ChapterNumber { get; init; }
public string Title { get; init; } = string.Empty;
public int WordCount { get; init; }
public string? ChapterText { get; init; }
public int? StartPosition { get; init; }
public int? ExistingChapterID { get; init; }
}

View File

@ -186,8 +186,10 @@ public class Program
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();

View File

@ -198,24 +198,15 @@ public sealed class OnboardingService(
var (userId, preview, review, _, _, _) = context.Value;
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
throw new InvalidOperationException("Review the scan and choose Save and continue before building the project structure.");
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
}
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
ValidateScanReview(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
var chapterKeys = chapterDecisions.Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var sceneDecisions = review.Scenes
.Where(scene => scene.Include && chapterKeys.Contains(scene.TemporaryChapterKey))
.OrderBy(scene => chapterDecisions.FindIndex(chapter => chapter.TemporaryChapterKey == scene.TemporaryChapterKey))
.ThenBy(scene => scene.SceneNumberWithinChapter)
.ToList();
var characterDecisions = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
await PublishBuildProgress(userId, preview.PreviewID, "Creating scenes...", 35, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress);
var request = new OnboardingManuscriptBuildRequest
{
PreviewID = preview.PreviewID,
@ -229,25 +220,34 @@ public sealed class OnboardingService(
SortOrder = (index + 1) * 10,
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
}).ToList(),
Scenes = sceneDecisions.Select((scene, index) => new OnboardingBuildSceneRequest
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title!,
SortOrder = (index + 1) * 10,
WordCount = preview.Scenes.FirstOrDefault(item => item.TemporarySceneKey == scene.TemporarySceneKey)?.WordCount ?? 0
}).ToList(),
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
{
TemporaryCharacterKey = character.TemporaryCharacterKey,
Name = character.Name,
ExistingCharacterID = character.ExistingCharacterID
}).ToList()
Scenes = [],
Characters = []
};
await PublishBuildProgress(userId, preview.PreviewID, "Creating characters...", 60, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress);
var result = await builds.BuildAsync(userId, request)
?? throw new InvalidOperationException($"The project structure could not be built. Approved chapters: {request.Chapters.Count}, scenes: {request.Scenes.Count}, characters: {request.Characters.Count}. Return to review, save your selections, and try Build Project again.");
?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again.");
result = new OnboardingManuscriptBuildResult
{
BuildID = result.BuildID,
PreviewID = result.PreviewID,
ProjectID = result.ProjectID,
BookID = result.BookID,
ManuscriptDocumentID = result.ManuscriptDocumentID,
Status = result.Status,
MarkerStatus = result.MarkerStatus,
ChaptersCreated = result.ChaptersCreated,
ScenesCreated = result.ScenesCreated,
CharactersCreated = result.CharactersCreated,
CharactersReused = result.CharactersReused,
SceneAppearancesCreated = result.SceneAppearancesCreated,
AlreadyBuilt = result.AlreadyBuilt,
Message = "Approved chapters are ready for Story Intelligence.",
ChapterMappings = result.ChapterMappings,
SceneMappings = result.SceneMappings,
CharacterMappings = result.CharacterMappings,
MarkerWarning = result.MarkerWarning
};
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);

View File

@ -0,0 +1,382 @@
using System.Collections.Concurrent;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IOnboardingStoryIntelligenceService
{
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
}
public sealed class OnboardingStoryIntelligenceService(
IOnboardingRepository onboardingRepository,
IOnboardingService onboarding,
IManuscriptScanPreviewStore scanStore,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
ICurrentUserService currentUser,
ILogger<OnboardingStoryIntelligenceService> logger) : IOnboardingStoryIntelligenceService
{
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
private const string SourceType = "OnboardingWordCompanionChapter";
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
{
var userId = RequireUserId();
var context = await GetContextAsync(userId);
if (context is null)
{
return new StoryIntelligenceOverviewViewModel
{
Message = "Finish the Word Companion scan and review before analysing your manuscript."
};
}
var (_, preview, review, build, wizard) = context.Value;
var included = IncludedChapters(preview, review);
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
return new StoryIntelligenceOverviewViewModel
{
PreviewID = preview.PreviewID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
SelectedProjectName = wizard.SelectedProjectName,
SelectedBookTitle = wizard.SelectedBookTitle,
ApprovedChapterCount = included.Count,
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
MissingChapterTextCount = missingTextCount,
ExistingBatchID = existingBatch?.BatchID,
CanStart = included.Count > 0 && missingTextCount == 0,
Message = build is null
? "PlotDirector will prepare the approved chapters, then analyse them with Story Intelligence."
: "The approved chapters are ready to analyse."
};
}
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
{
var userId = RequireUserId();
var context = await GetContextAsync(userId);
if (context is null)
{
return null;
}
var (state, preview, review, build, wizard) = context.Value;
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
if (existing is not null)
{
return existing;
}
build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID)
?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis.");
var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal);
var included = IncludedChapters(preview, review);
if (included.Count == 0)
{
throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript.");
}
if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)))
{
throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
foreach (var chapter in included)
{
if (!chapterIds.TryGetValue(chapter.Preview.TemporaryChapterKey, out var chapterId))
{
chapterId = chapter.Preview.ExistingChapterID ?? 0;
}
if (chapterId <= 0)
{
throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis.");
}
var text = chapter.Preview.ChapterText!.Trim();
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber,
SourceType = SourceType,
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
ChapterText = text,
SourceFileName = preview.DocumentTitle,
SourceWordCount = CountWords(text),
SourceCharacterCount = text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
ChapterNumber = chapter.Decision.ChapterNumber,
ChapterTitle = chapter.Decision.Title,
ChapterID = chapterId,
RunID = runId
});
}
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = state.UserOnboardingStateID,
PreviewID = preview.PreviewID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
ProjectName = wizard.SelectedProjectName,
BookTitle = wizard.SelectedBookTitle,
Items = batchItems
};
await batchStore.SaveAsync(batch);
logger.LogInformation(
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
batch.BatchID,
userId,
preview.ProjectID,
preview.BookID,
batchItems.Count);
return batch;
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
var chapters = new List<StoryIntelligenceOnboardingChapterViewModel>();
foreach (var item in batch.Items)
{
var run = await runs.GetRunAsync(item.RunID);
if (run is null)
{
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
{
TemporaryChapterKey = item.TemporaryChapterKey,
ChapterNumber = item.ChapterNumber,
ChapterTitle = item.ChapterTitle,
ChapterID = item.ChapterID,
RunID = item.RunID,
Status = "Missing",
ErrorMessage = "This analysis run could not be found."
});
continue;
}
var commit = await runs.GetImportCommitAsync(item.RunID);
var confirmation = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
? await commits.BuildConfirmationAsync(item.RunID)
: null;
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
{
TemporaryChapterKey = item.TemporaryChapterKey,
ChapterNumber = item.ChapterNumber,
ChapterTitle = item.ChapterTitle,
ChapterID = item.ChapterID,
RunID = item.RunID,
Status = run.Status,
CurrentStage = run.CurrentStage,
CurrentMessage = run.CurrentMessage,
TotalDetectedScenes = run.TotalDetectedScenes,
CompletedScenes = run.CompletedScenes,
FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs,
FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage,
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
CanCommit = confirmation?.CanCommit == true,
ScenesCreated = commit?.ScenesCreated ?? 0,
CommitMessage = commit?.Notes,
Blockers = confirmation?.Blockers ?? [],
Warnings = confirmation?.Warnings ?? []
});
}
return new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
PreviewID = batch.PreviewID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
ProjectName = batch.ProjectName,
BookTitle = batch.BookTitle,
Chapters = chapters
};
}
public async Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
if (progress is null)
{
return null;
}
foreach (var chapter in progress.Chapters.Where(chapter => chapter.IsActive))
{
await runs.RequestCancelAsync(chapter.RunID);
}
return await GetProgressAsync(batchId);
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId)
{
var progress = await GetProgressAsync(batchId);
if (progress is null || progress.Chapters.All(chapter => chapter.RunID != runId))
{
return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This chapter analysis could not be found." });
}
var result = await commits.CommitAsync(runId, RequireUserId());
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
if (progress is null)
{
return null;
}
return new StoryIntelligenceCompletionViewModel
{
BatchID = progress.BatchID,
ProjectID = progress.ProjectID,
BookID = progress.BookID,
ProjectName = progress.ProjectName,
BookTitle = progress.BookTitle,
ChaptersCommitted = progress.CommittedChapterCount,
ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated)
};
}
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
{
var state = await onboardingRepository.GetAsync(userId);
if (state is null || !state.ProjectID.HasValue || !state.BookID.HasValue)
{
return null;
}
var preview = await scanStore.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
if (preview is null)
{
return null;
}
var review = await scanStore.GetReviewAsync(userId, preview.PreviewID);
if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
return null;
}
var build = await scanStore.GetBuildResultAsync(userId, preview.PreviewID);
var wizard = await onboarding.GetWizardAsync();
return (state, preview, review, build, wizard);
}
private int RequireUserId()
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
return review.Chapters
.Where(chapter => chapter.Include && chapters.ContainsKey(chapter.TemporaryChapterKey))
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => new IncludedChapter(chapter, chapters[chapter.TemporaryChapterKey]))
.ToList();
}
private static int CountWords(string value)
=> string.IsNullOrWhiteSpace(value)
? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private sealed record IncludedChapter(ManuscriptScanChapterReviewDecision Decision, ManuscriptScanChapterPreview Preview);
}
public interface IOnboardingStoryIntelligenceBatchStore
{
Task SaveAsync(OnboardingStoryIntelligenceBatch batch);
Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId);
Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId);
}
public sealed class OnboardingStoryIntelligenceBatchStore : IOnboardingStoryIntelligenceBatchStore
{
private readonly ConcurrentDictionary<Guid, OnboardingStoryIntelligenceBatch> batches = new();
public Task SaveAsync(OnboardingStoryIntelligenceBatch batch)
{
batches[batch.BatchID] = batch;
return Task.CompletedTask;
}
public Task<OnboardingStoryIntelligenceBatch?> GetAsync(int userId, Guid batchId)
=> Task.FromResult(batches.TryGetValue(batchId, out var batch) && batch.UserID == userId ? batch : null);
public Task<OnboardingStoryIntelligenceBatch?> GetLatestForPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(batches.Values
.Where(batch => batch.UserID == userId && batch.PreviewID == previewId)
.OrderByDescending(batch => batch.CreatedUtc)
.FirstOrDefault());
}
public sealed class OnboardingStoryIntelligenceBatch
{
public Guid BatchID { get; init; } = Guid.NewGuid();
public int UserID { get; init; }
public int OnboardingID { get; init; }
public Guid PreviewID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
}
public sealed class OnboardingStoryIntelligenceBatchItem
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public int ChapterID { get; init; }
public int RunID { get; init; }
}

View File

@ -106,28 +106,79 @@ public sealed class OnboardingNudgeViewModel
public sealed class StoryIntelligenceOverviewViewModel
{
public Guid? PreviewID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? SelectedProjectName { get; init; }
public string? SelectedBookTitle { get; init; }
public int ApprovedChapterCount { get; init; }
public int ApprovedWordCount { get; init; }
public int MissingChapterTextCount { get; init; }
public string? Message { get; init; }
public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
public bool CanStart => ProjectID.HasValue && BookID.HasValue && ExistingJob?.IsActive != true;
public bool CanStart { get; init; }
}
public sealed class StoryIntelligenceProgressViewModel
{
public StoryIntelligenceJobProgress Job { get; init; } = new();
public Guid? BatchID { get; init; }
public Guid? PreviewID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed);
public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.TotalDetectedScenes ?? 0);
public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0);
public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0);
public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive);
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
}
public sealed class StoryIntelligenceCompletionViewModel
{
public StoryIntelligenceJobProgress Job { get; init; } = new();
public Guid? BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public int ChaptersCommitted { get; init; }
public int ScenesCreated { get; init; }
}
public sealed class StoryIntelligenceOnboardingChapterViewModel
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public int ChapterID { get; init; }
public int RunID { get; init; }
public string Status { get; init; } = string.Empty;
public string? CurrentStage { get; init; }
public string? CurrentMessage { get; init; }
public int? TotalDetectedScenes { get; init; }
public int? CompletedScenes { get; init; }
public int? FailedScenes { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public string? FailureStage { get; init; }
public string? ErrorMessage { get; init; }
public bool HasCompletedCommit { get; init; }
public bool CanCommit { get; init; }
public int ScenesCreated { get; init; }
public string? CommitMessage { get; init; }
public IReadOnlyList<string> Blockers { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public bool IsActive => Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running;
public bool IsRunComplete => Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings;
public bool IsFailed => Status is StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
}
public sealed class StoryIntelligenceDashboardViewModel

View File

@ -1,12 +1,12 @@
@model OnboardingManuscriptBuildResult
@{
ViewData["Title"] = "Project structure created";
ViewData["Title"] = "Chapters prepared";
}
<section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey">
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Build Project", "Complete" })
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Prepare Chapters", "Complete" })
{
<li class="is-complete">
<span></span>
@ -15,12 +15,12 @@
}
</ol>
<div class="onboarding-copy">
<p class="eyebrow">Build Project</p>
<p class="eyebrow">Prepare Chapters</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="build-complete-title">Your project structure has been created.</h1>
<h1 id="build-complete-title">Your chapters are ready.</h1>
</div>
<p>@(Model.AlreadyBuilt ? "This manuscript has already been built into PlotDirector, so nothing was duplicated." : "PlotDirector created the approved chapters, scenes, characters and scene appearances.")</p>
<p>@(Model.AlreadyBuilt ? "These chapters have already been prepared in PlotDirector, so nothing was duplicated." : "PlotDirector prepared the approved chapters. Story Intelligence will create scenes after you review the analysis.")</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
{
<p class="text-warning">@Model.MarkerWarning</p>
@ -33,28 +33,28 @@
<strong>@Model.ChaptersCreated</strong>
</div>
<div>
<span>Scenes</span>
<span>Scenes created now</span>
<strong>@Model.ScenesCreated</strong>
</div>
<div>
<span>Characters</span>
<span>Characters created now</span>
<strong>@(Model.CharactersCreated + Model.CharactersReused)</strong>
</div>
<div>
<span>Scene appearances</span>
<span>Scene appearances now</span>
<strong>@Model.SceneAppearancesCreated</strong>
</div>
</div>
<section class="onboarding-complete-next">
<h2>Ready for your next pass</h2>
<p>Your project now has a working story map. Open the overview to inspect the structure, or head straight into the writer workspace to keep drafting.</p>
<h2>Next: analyse manuscript</h2>
<p>Story Intelligence will detect scenes from the approved chapter text. You will review the results before PlotDirector creates scenes.</p>
</section>
<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="StoryIntelligence">Continue to Story Intelligence</a>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a>
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div>
</div>
</section>

View File

@ -12,7 +12,7 @@
new { Label = "Connect Word", Order = 5 },
new { Label = "Scan", Order = 6 },
new { Label = "Review", Order = 7 },
new { Label = "Build Project", Order = 8 },
new { Label = "Prepare Chapters", Order = 8 },
new { Label = "Complete", Order = 9 }
};
}
@ -340,7 +340,7 @@
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Review saved. PlotDirector can now create the approved structure.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Ready to build chapters, scenes, characters and scene appearances.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Ready to prepare approved chapters for Story Intelligence.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -351,7 +351,7 @@
type="button"
data-onboarding-build-start
data-preview-id="@Model.ScanState.PreviewID">
Build Project
Prepare Chapters
</button>
}
</section>

View File

@ -23,8 +23,8 @@
<div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="scan-review-title">Review manuscript scan</h1>
<p>Nothing has been added to your project yet. Choose the chapters, scenes and character candidates that should become your first PlotDirector structure.</p>
<p>You can keep this light now and refine everything later inside the project.</p>
<p>Nothing has been added to your story map yet. Choose the chapters Story Intelligence should analyse for scene creation.</p>
<p>Scene and character scan details are shown for context only in this phase.</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -145,7 +145,7 @@
<h2>Character candidates</h2>
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
{
<p>No repeated character names were found yet. You can still build the project structure and add characters later.</p>
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
}
else
{

View File

@ -1,68 +1,75 @@
@model StoryIntelligenceOverviewViewModel
@{
ViewData["Title"] = "Story Intelligence";
ViewData["Title"] = "Analyse manuscript";
}
<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>
<p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="story-intelligence-title">Analyse chapters with Story Intelligence</h1>
<p>PlotDirector will read the approved chapter text, detect scene boundaries, and prepare scenes for your review before anything is created.</p>
</div>
@if (Model.ExistingJob?.IsActive == true)
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<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>
<div class="alert alert-danger">@error</div>
}
else if (Model.ExistingJob?.IsCompleted == true)
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (Model.ExistingBatchID.HasValue)
{
<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>
<h2>Analysis has already started</h2>
<p>You can continue reviewing progress for the approved chapters.</p>
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Review scenes</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>
<span>Chapters</span>
<strong>@Model.ApprovedChapterCount</strong>
<p>Approved chapters from your manuscript review.</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>
<span>Words</span>
<strong>@Model.ApprovedWordCount.ToString("N0")</strong>
<p>Chapter text ready for analysis.</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>
<span>Creates</span>
<strong>Scenes only</strong>
<p>Characters, locations, assets and relationships will not be imported yet.</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>
<span>Review</span>
<strong>You approve creation</strong>
<p>Scenes are created only after the analysis completes and you choose Create scenes.</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>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<section class="onboarding-form-section">
<p>@Model.Message</p>
@if (Model.MissingChapterTextCount > 0)
{
<p class="text-danger">@Model.MissingChapterTextCount chapter(s) do not include scanned text. Scan the manuscript again before analysing.</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>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</form>
</div>
}

View File

@ -1,6 +1,6 @@
@model StoryIntelligenceCompletionViewModel
@{
ViewData["Title"] = "Story Intelligence ready";
ViewData["Title"] = "Scenes created";
}
<section class="onboarding-shell" aria-labelledby="story-complete-title">
@ -9,38 +9,33 @@
<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>
<h1 id="story-complete-title">Your scenes have been created.</h1>
</div>
<p>The analysis engine is prepared. In the next phase PlotDirector will begin analysing your manuscript.</p>
<p>PlotDirector created scenes from the approved manuscript chapters. Characters, locations, assets and relationships were left untouched.</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>
<span>Chapters</span>
<strong>@Model.ChaptersCommitted</strong>
</div>
<div>
<span>Progress</span>
<strong>@Model.Job.ProgressPercent%</strong>
<span>Scenes created</span>
<strong>@Model.ScenesCreated</strong>
</div>
<div>
<span>Elapsed</span>
<strong>@Model.Job.ElapsedTime</strong>
<span>Project</span>
<strong>@Model.ProjectName</strong>
</div>
<div>
<span>Stage</span>
<strong>@Model.Job.CurrentStage</strong>
<span>Book</span>
<strong>@Model.BookTitle</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-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to Dashboard</a>
</div>
</div>

View File

@ -1,60 +1,170 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Preparing Story Intelligence";
ViewData["Title"] = "Review scenes";
}
<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-panel onboarding-review-panel">
@if (Model.HasActiveRuns)
{
<meta http-equiv="refresh" content="10" />
}
<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>
<h1 id="story-progress-title">Review scenes</h1>
<p>PlotDirector is analysing the approved chapters. When a chapter is ready, you can create its scenes in PlotDirector.</p>
</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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.CompletedChapterCount / @Model.ChapterCount</strong>
</div>
<div class="onboarding-scan-progress" aria-hidden="true">
<span data-story-intelligence-progress-bar style="width:@Model.Job.ProgressPercent%"></span>
<div>
<span>Scenes detected</span>
<strong>@Model.TotalDetectedScenes</strong>
</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>
<span>Scenes analysed</span>
<strong>@Model.TotalCompletedScenes</strong>
</div>
<div>
<span>Failures</span>
<strong>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div>
</div>
<section class="onboarding-review-grid" aria-label="Chapter analysis progress">
<div class="onboarding-review-section">
<h2>Chapters</h2>
<div class="onboarding-review-chapters">
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<details class="onboarding-review-chapter" open>
<summary>
<span>@chapter.ChapterNumber. @chapter.ChapterTitle</span>
<strong>@ChapterStatusLabel(chapter)</strong>
</summary>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Status</span>
<strong>@chapter.Status</strong>
</div>
<div>
<span>Scenes</span>
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong>
</div>
<div>
<span>Failed</span>
<strong>@((chapter.FailedScenes ?? 0).ToString("N0"))</strong>
</div>
<div>
<span>Tokens</span>
<strong>@(chapter.TotalTokens?.ToString("N0") ?? "-")</strong>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(chapter.CurrentMessage))
{
<p>@chapter.CurrentMessage</p>
}
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{
<p class="text-danger">@chapter.ErrorMessage</p>
}
@if (chapter.Warnings.Any())
{
<ul>
@foreach (var warning in chapter.Warnings)
{
<li>@warning</li>
}
</ul>
}
@if (chapter.Blockers.Any())
{
<ul>
@foreach (var blocker in chapter.Blockers)
{
<li class="text-danger">@blocker</li>
}
</ul>
}
<div class="onboarding-actions">
@if (chapter.HasCompletedCommit)
{
<span class="btn btn-outline-secondary disabled">@chapter.ScenesCreated scene(s) created</span>
}
else if (chapter.CanCommit && Model.BatchID.HasValue)
{
<form asp-action="CommitStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<input type="hidden" name="runId" value="@chapter.RunID" />
<button class="btn btn-primary" type="submit">Create scenes</button>
</form>
}
else if (chapter.IsRunComplete)
{
<span class="btn btn-outline-secondary disabled">Review needed</span>
}
</div>
</details>
}
</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>
@if (Model.BatchID.HasValue)
{
<form asp-action="CancelStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Refresh</a>
<a class="btn btn-primary @(Model.AllCommitted ? string.Empty : "disabled")"
asp-action="StoryIntelligenceComplete"
asp-route-batchId="@Model.BatchID"
aria-disabled="@(!Model.AllCommitted)">Continue</a>
}
</div>
</div>
</section>
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
@functions {
private static string ChapterStatusLabel(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit)
{
return "Scenes created";
}
if (chapter.CanCommit)
{
return "Ready to create";
}
if (chapter.IsActive)
{
return "Analysing";
}
if (chapter.IsFailed)
{
return "Needs attention";
}
return "Reviewing";
}
}

View File

@ -70,11 +70,11 @@
</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>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">@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>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">@Model.StoryIntelligence.ButtonText</a>
}
else
{

View File

@ -1932,6 +1932,8 @@
chapterNumber: chapters.length + 1,
title,
wordCount: 0,
chapterTextParagraphs: [],
chapterText: "",
startPosition: paragraph?.index ?? null,
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
};
@ -1966,6 +1968,7 @@
}
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
scene.wordCount += words;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
@ -1987,6 +1990,7 @@
startChapter(paragraph, text);
totalWordCount += words;
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
return;
}
@ -2018,6 +2022,13 @@
throw new Error("No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.");
}
chapters.forEach((item) => {
item.chapterText = Array.isArray(item.chapterTextParagraphs)
? item.chapterTextParagraphs.join("\n\n")
: "";
delete item.chapterTextParagraphs;
});
const documentTextForDiscovery = documentText.join("\n");
return {
previewID: "00000000-0000-0000-0000-000000000000",

File diff suppressed because one or more lines are too long

View File

@ -149,7 +149,7 @@
connection.on("OnboardingScanCompleted", applyScanState);
connection.on("OnboardingScanFailed", applyScanState);
connection.on("OnboardingBuildProgress", (state) => {
const message = field(state, "message", "Message") || "Building project structure...";
const message = field(state, "message", "Message") || "Preparing chapters...";
const percent = field(state, "percentComplete", "PercentComplete");
const safePercent = Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0));
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
@ -207,7 +207,7 @@
node.classList.add("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Creating the approved project structure...";
node.textContent = "Preparing the approved chapters...";
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "0%";
@ -225,7 +225,7 @@
node.classList.remove("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = error?.message || "The project structure could not be built. Check the review and try again.";
node.textContent = error?.message || "The approved chapters could not be prepared. Check the review and try again.";
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "";