410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
(() => {
|
|
const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")];
|
|
const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")];
|
|
const onboardingRoots = [...document.querySelectorAll("[data-story-intelligence-onboarding]")];
|
|
const legacyRoots = [...progressRoots, ...dashboardRoots];
|
|
const roots = [...legacyRoots, ...onboardingRoots];
|
|
if (roots.length === 0 || !window.signalR) {
|
|
return;
|
|
}
|
|
|
|
const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null;
|
|
const legacyJobIds = [...new Set(legacyRoots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))];
|
|
const runIds = [...new Set(onboardingRoots
|
|
.flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(","))
|
|
.map((value) => value.trim())
|
|
.filter(Boolean))];
|
|
|
|
const numberText = (value) => {
|
|
const number = Number.parseInt(value ?? "0", 10);
|
|
return Number.isFinite(number) ? number.toLocaleString() : "0";
|
|
};
|
|
|
|
const friendlyStatus = (status) => {
|
|
switch (status) {
|
|
case "Pending":
|
|
return "Waiting";
|
|
case "Running":
|
|
return "Reading";
|
|
case "Completed":
|
|
return "Ready to review";
|
|
case "CompletedWithWarnings":
|
|
return "Ready to review, with notes";
|
|
case "Failed":
|
|
return "Needs attention";
|
|
case "Cancelled":
|
|
return "Cancelled";
|
|
default:
|
|
return "Waiting";
|
|
}
|
|
};
|
|
|
|
const friendlyStage = (stage, status) => {
|
|
if (status === "Completed" || status === "CompletedWithWarnings") {
|
|
return "Ready to review";
|
|
}
|
|
if (status === "Failed") {
|
|
return "Needs attention";
|
|
}
|
|
if (status === "Cancelled") {
|
|
return "Cancelled";
|
|
}
|
|
|
|
switch (stage) {
|
|
case "Queued":
|
|
return "Waiting";
|
|
case "DocumentRead":
|
|
return "Preparing chapter";
|
|
case "ChapterStructure":
|
|
return "Finding scenes";
|
|
case "SceneSplit":
|
|
return "Preparing scenes";
|
|
case "SceneIntelligence":
|
|
return "Reading scenes";
|
|
case "Validation":
|
|
return "Checking results";
|
|
case "Persistence":
|
|
return "Saving analysis";
|
|
default:
|
|
return "Reading";
|
|
}
|
|
};
|
|
|
|
const friendlyRemaining = (value, completedScenes, totalScenes) => {
|
|
if (totalScenes > 0 && completedScenes >= totalScenes) {
|
|
return "Almost done";
|
|
}
|
|
|
|
if (!value || value === "Calculating") {
|
|
return "Estimating...";
|
|
}
|
|
|
|
const minutes = Number.parseInt(String(value).replace(/[^0-9]/g, ""), 10);
|
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
return "Estimating...";
|
|
}
|
|
|
|
return `About ${minutes}-${Math.max(minutes + 1, minutes * 2)} minutes`;
|
|
};
|
|
|
|
const possibleDiscoveryText = (text) => String(text || "")
|
|
.replace(/^Character detected:/i, "Possible character found:")
|
|
.replace(/^Possible character detected:/i, "Possible character found:")
|
|
.replace(/^Location detected:/i, "Possible location found:")
|
|
.replace(/^Possible location detected:/i, "Possible location found:")
|
|
.replace(/^Asset detected:/i, "Possible asset found:")
|
|
.replace(/^Possible asset detected:/i, "Possible asset found:")
|
|
.replace(/^Timeline clue detected:/i, "Timeline clue found:")
|
|
.replace(/^Relationship detected:/i, "Relationship signal found:");
|
|
|
|
const friendlyMessage = (message, stage, status, sceneNumber) => {
|
|
if (status === "Cancelled") {
|
|
return "Analysis was cancelled. Any completed chapters remain available for review.";
|
|
}
|
|
if (status === "Failed") {
|
|
return "This chapter needs attention before scenes can be created.";
|
|
}
|
|
if (status === "Completed" || status === "CompletedWithWarnings") {
|
|
return "This chapter is ready to review.";
|
|
}
|
|
|
|
switch (stage) {
|
|
case "Queued":
|
|
return "Waiting to read this chapter.";
|
|
case "DocumentRead":
|
|
return "Preparing this chapter.";
|
|
case "ChapterStructure":
|
|
return "Finding scenes in this chapter.";
|
|
case "SceneSplit":
|
|
return "Preparing the detected scenes.";
|
|
case "SceneIntelligence":
|
|
return sceneNumber ? `Reading scene ${numberText(sceneNumber)}.` : "Reading the detected scenes.";
|
|
case "Validation":
|
|
return "Checking the results.";
|
|
default:
|
|
return message || "Waiting for analysis to begin.";
|
|
}
|
|
};
|
|
|
|
const updateLegacyRoot = (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 applyLegacyProgress = (state) => {
|
|
const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId");
|
|
if (!jobId) {
|
|
return;
|
|
}
|
|
|
|
legacyRoots
|
|
.filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase())
|
|
.forEach((root) => updateLegacyRoot(root, state));
|
|
|
|
if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) {
|
|
window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`;
|
|
}
|
|
};
|
|
|
|
const statusLabel = (state) => {
|
|
const status = field(state, "status", "Status") || "";
|
|
if (status === "Completed" || status === "CompletedWithWarnings") {
|
|
return "Ready to create";
|
|
}
|
|
if (status === "Failed" || status === "Cancelled") {
|
|
return "Needs attention";
|
|
}
|
|
if (status === "Pending" || status === "Running") {
|
|
return "Analysing";
|
|
}
|
|
return "Reviewing";
|
|
};
|
|
|
|
const addFeedItem = (list, text) => {
|
|
if (!list || !text) {
|
|
return;
|
|
}
|
|
|
|
const item = document.createElement("li");
|
|
item.textContent = possibleDiscoveryText(text);
|
|
list.prepend(item);
|
|
while (list.children.length > 8) {
|
|
list.lastElementChild?.remove();
|
|
}
|
|
};
|
|
|
|
const recalculateOnboardingTotals = (root) => {
|
|
const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true";
|
|
const chapters = [...root.querySelectorAll("[data-story-run-id]")];
|
|
const totals = chapters.reduce((current, chapter) => {
|
|
const status = chapter.dataset.storyRawStatus || "";
|
|
const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
|
|
const total = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
|
|
const failed = Number.parseInt(chapter.querySelector("[data-story-run-failed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
|
|
current.completedChapters += status === "Completed" || status === "CompletedWithWarnings" ? 1 : 0;
|
|
current.failedChapters += status === "Failed" || status === "Cancelled" ? 1 : 0;
|
|
current.completedScenes += completed;
|
|
current.totalScenes += total;
|
|
current.failedScenes += failed;
|
|
return current;
|
|
}, { completedChapters: 0, failedChapters: 0, completedScenes: 0, totalScenes: 0, failedScenes: 0 });
|
|
|
|
const chapterProgress = chapters.reduce((total, chapter) => {
|
|
const status = chapter.dataset.storyRawStatus || "";
|
|
if (status === "Completed" || status === "CompletedWithWarnings" || status === "Failed" || status === "Cancelled") {
|
|
return total + 1;
|
|
}
|
|
if (status === "Pending") {
|
|
return total;
|
|
}
|
|
const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
|
|
const detected = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0;
|
|
if (detected > 0) {
|
|
return total + Math.min(0.95, 0.25 + ((completed / Math.max(1, detected)) * 0.7));
|
|
}
|
|
return total + 0.15;
|
|
}, 0);
|
|
const calculatedPercent = Math.floor((chapterProgress * 100) / Math.max(1, chapters.length));
|
|
const previousPercent = Number.parseInt(root.dataset.storyMaxProgressPercent || "0", 10) || 0;
|
|
const percent = Math.max(previousPercent, Math.max(0, Math.min(100, calculatedPercent)));
|
|
root.dataset.storyMaxProgressPercent = String(percent);
|
|
|
|
root.querySelectorAll("[data-story-overall-percent]").forEach((node) => {
|
|
node.textContent = `${Math.max(0, Math.min(100, percent))}%`;
|
|
});
|
|
root.querySelectorAll("[data-story-overall-progress-bar]").forEach((node) => {
|
|
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
});
|
|
root.querySelectorAll("[data-story-chapters-completed]").forEach((node) => {
|
|
node.textContent = numberText(totals.completedChapters);
|
|
});
|
|
root.querySelectorAll("[data-story-scenes-completed]").forEach((node) => {
|
|
node.textContent = numberText(totals.completedScenes);
|
|
});
|
|
root.querySelectorAll("[data-story-scenes-total]").forEach((node) => {
|
|
node.textContent = numberText(totals.totalScenes);
|
|
});
|
|
root.querySelectorAll("[data-story-failures]").forEach((node) => {
|
|
node.textContent = numberText(totals.failedChapters + totals.failedScenes);
|
|
});
|
|
|
|
const active = chapters.some((chapter) => {
|
|
const status = chapter.dataset.storyRawStatus || "";
|
|
return status === "Pending" || status === "Running";
|
|
});
|
|
root.dataset.storyIntelligenceHasActiveRuns = String(active);
|
|
|
|
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
|
|
root.dataset.reloadScheduled = "true";
|
|
window.setTimeout(() => window.location.reload(), 1200);
|
|
}
|
|
};
|
|
|
|
const applyRunProgress = (state) => {
|
|
const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId");
|
|
if (!runId) {
|
|
return;
|
|
}
|
|
|
|
onboardingRoots.forEach((root) => {
|
|
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
|
|
if (!chapter) {
|
|
return;
|
|
}
|
|
|
|
const status = field(state, "status", "Status") || "Pending";
|
|
const stage = field(state, "currentStage", "CurrentStage") || status;
|
|
const message = field(state, "currentMessage", "CurrentMessage") || "";
|
|
const completed = field(state, "completedScenes", "CompletedScenes");
|
|
const total = field(state, "totalDetectedScenes", "TotalDetectedScenes");
|
|
const failed = field(state, "failedScenes", "FailedScenes");
|
|
const tokens = field(state, "totalTokens", "TotalTokens");
|
|
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
|
|
const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating";
|
|
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
|
|
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
|
|
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
|
|
const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || [];
|
|
const error = field(state, "errorMessage", "ErrorMessage");
|
|
|
|
chapter.querySelectorAll("[data-story-chapter-status-label]").forEach((node) => {
|
|
node.textContent = statusLabel(state);
|
|
});
|
|
chapter.dataset.storyRawStatus = status;
|
|
chapter.querySelectorAll("[data-story-run-status]").forEach((node) => {
|
|
node.textContent = friendlyStatus(status);
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-raw-status]").forEach((node) => {
|
|
node.textContent = status;
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-raw-stage]").forEach((node) => {
|
|
node.textContent = stage;
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-message]").forEach((node) => {
|
|
node.textContent = displayMessage;
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-completed]").forEach((node) => {
|
|
if (completed !== null) {
|
|
node.textContent = numberText(completed);
|
|
}
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-total]").forEach((node) => {
|
|
if (total !== null) {
|
|
node.textContent = numberText(total);
|
|
}
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-failed]").forEach((node) => {
|
|
if (failed !== null) {
|
|
node.textContent = numberText(failed);
|
|
}
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-review-note]").forEach((node) => {
|
|
const failedCount = Number.parseInt(failed || "0", 10) || 0;
|
|
node.textContent = failedCount > 0 ? "Needs review after analysis completes." : "";
|
|
node.hidden = failedCount <= 0;
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-tokens]").forEach((node) => {
|
|
node.textContent = tokens === null ? "-" : numberText(tokens);
|
|
});
|
|
chapter.querySelectorAll("[data-story-run-error]").forEach((node) => {
|
|
node.textContent = error || "";
|
|
node.hidden = !error;
|
|
});
|
|
|
|
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
|
|
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
|
|
});
|
|
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
|
|
node.textContent = friendlyStage(stage, status);
|
|
});
|
|
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
|
|
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
|
|
});
|
|
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
|
|
node.textContent = displayMessage;
|
|
});
|
|
root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
|
|
node.textContent = elapsed;
|
|
});
|
|
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
|
|
node.textContent = friendlyRemaining(remaining, Number.parseInt(completed || "0", 10) || 0, Number.parseInt(total || "0", 10) || 0);
|
|
});
|
|
|
|
const summaryNode = root.querySelector("[data-story-latest-summary]");
|
|
if (summaryNode && latestSummary) {
|
|
summaryNode.textContent = latestSummary;
|
|
summaryNode.hidden = false;
|
|
const emptyNode = root.querySelector("[data-story-latest-empty]");
|
|
if (emptyNode) {
|
|
emptyNode.hidden = true;
|
|
}
|
|
}
|
|
|
|
const feed = root.querySelector("[data-story-discovery-feed]");
|
|
[...discoveries].reverse().forEach((discovery) => addFeedItem(feed, discovery));
|
|
|
|
recalculateOnboardingTotals(root);
|
|
});
|
|
};
|
|
|
|
const connection = new signalR.HubConnectionBuilder()
|
|
.withUrl("/hubs/story-intelligence")
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
|
|
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
|
|
connection.onreconnected(() => {
|
|
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
|
|
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
|
|
});
|
|
|
|
connection.start()
|
|
.then(() => Promise.all([
|
|
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
|
|
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
|
|
]))
|
|
.then((states) => {
|
|
states.filter(Boolean).forEach((state) => {
|
|
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
|
|
applyRunProgress(state);
|
|
} else {
|
|
applyLegacyProgress(state);
|
|
}
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
})();
|