Phase 16P - Word Companion Performance Hardening and Office.js Snapshot Optimisation

This commit is contained in:
Nick Beckley 2026-06-29 13:48:02 +01:00
parent 524515e9b6
commit db6f0992c1
2 changed files with 201 additions and 69 deletions

View File

@ -199,6 +199,8 @@
let runtimeSelectionDirty = false;
let runtimeLastActivityAt = 0;
let runtimeUpdatePending = false;
let runtimeGeneration = 0;
let lastCurrentSceneSnapshot = null;
const runtimeIdleDelayMs = 1200;
let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
@ -244,6 +246,9 @@
runtimeLastDebugAt = now;
console.debug(`[Word Companion Runtime] ${message}`);
};
const runtimeTiming = (message) => {
console.debug(`[Word Companion Timing] ${message}`);
};
const setOfficeStatus = (message) => {
if (officeStatus) {
@ -586,6 +591,8 @@
documentStructureCache = null;
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
runtimeGeneration += 1;
lastCurrentSceneSnapshot = null;
resetRuntimeCharacterAliasCache();
resetRuntimeAssetAliasCache();
resetRuntimeLocationAliasCache();
@ -2207,73 +2214,122 @@
return selectionParagraphs.items;
};
const recountCachedCurrentScene = async () => {
const runtimePerformanceNow = () =>
window.performance && typeof window.performance.now === "function" ? window.performance.now() : Date.now();
const isSnapshotFresh = (snapshot) => !!snapshot
&& !snapshot.stale
&& snapshot.generation === runtimeGeneration
&& snapshot.documentGuid === currentDocumentGuid()
&& snapshot.sceneId === resolvedSceneId;
const captureCurrentSceneSnapshot = async (options = {}) => {
const includeSelection = !!options.includeSelection;
const updateDisplay = options.updateDisplay !== false;
const generation = runtimeGeneration;
const startedAt = runtimePerformanceNow();
const project = selectedProject();
const book = selectedBook();
const scene = documentStructureCache?.currentScene;
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
return detectedSceneWordCount;
return null;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return detectedSceneWordCount;
return null;
}
return await window.Word.run(async (context) => {
const officeStartedAt = runtimePerformanceNow();
const wordSnapshot = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text");
let selectionParagraphs = null;
if (includeSelection) {
selectionParagraphs = context.document.getSelection().paragraphs;
selectionParagraphs.load("text,styleBuiltIn,style");
}
await context.sync();
let wordCount = 0;
const endIndex = Math.min(scene.endParagraphIndex, bodyParagraphs.items.length - 1);
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
for (let index = scene.startParagraphIndex; index <= endIndex; index += 1) {
const text = String(bodyParagraphs.items[index]?.text || "").trim();
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
continue;
}
wordCount += countWords(text);
}
return {
bodyTexts: bodyParagraphs.items.map((paragraph) => String(paragraph.text || "")),
selectionParagraphs: includeSelection && selectionParagraphs
? selectionParagraphs.items.map((paragraph) => ({
text: String(paragraph.text || ""),
styleBuiltIn: paragraph.styleBuiltIn,
style: paragraph.style
}))
: []
};
});
const officeMs = Math.round(runtimePerformanceNow() - officeStartedAt);
scene.wordCount = wordCount;
if (generation !== runtimeGeneration) {
runtimeTiming(`Office snapshot: ${officeMs}ms stale`);
return { stale: true, generation };
}
let selectionChanged = false;
if (includeSelection) {
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, wordSnapshot.selectionParagraphs);
selectionChanged = applyRuntimeSelection(selectedIndex);
}
const currentSceneItem = documentStructureCache?.currentScene;
if (!currentSceneItem || !Number.isInteger(currentSceneItem.startParagraphIndex) || !Number.isInteger(currentSceneItem.endParagraphIndex)) {
return null;
}
const texts = [];
let wordCount = 0;
const endIndex = Math.min(currentSceneItem.endParagraphIndex, wordSnapshot.bodyTexts.length - 1);
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
for (let index = currentSceneItem.startParagraphIndex; index <= endIndex; index += 1) {
const text = String(wordSnapshot.bodyTexts[index] || "").trim();
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
continue;
}
texts.push(text);
wordCount += countWords(text);
}
currentSceneItem.wordCount = wordCount;
if (updateDisplay) {
setCurrentStructure(
documentStructureCache.currentChapter?.title,
scene.title,
currentSceneItem.title,
wordCount,
documentStructureCache.currentChapter?.anchorId,
scene.anchorId,
currentSceneItem.anchorId,
documentStructureCache.currentChapter?.index
);
return wordCount;
});
}
const snapshot = {
documentGuid: currentDocumentGuid(),
projectId: project?.projectId || null,
bookId: book?.bookId || null,
chapterId: resolvedChapterId,
sceneId: resolvedSceneId,
chapterTitle: documentStructureCache.currentChapter?.title || "",
sceneTitle: currentSceneItem.title || "",
sceneText: texts.join("\n"),
wordCount,
selectionSignature: `${documentStructureCache.currentParagraphIndex ?? -1}:${currentSceneItem.startParagraphIndex}:${currentSceneItem.endParagraphIndex}`,
selectionChanged,
generation
};
lastCurrentSceneSnapshot = snapshot;
runtimeTiming(`Office snapshot: ${officeMs}ms; scene words: ${wordCount}; total snapshot: ${Math.round(runtimePerformanceNow() - startedAt)}ms`);
return snapshot;
};
const readCachedCurrentSceneText = async () => {
const scene = documentStructureCache?.currentScene;
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
return "";
const getFreshCurrentSceneSnapshot = async () => {
if (isSnapshotFresh(lastCurrentSceneSnapshot)) {
return lastCurrentSceneSnapshot;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return "";
}
return await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text");
await context.sync();
const texts = [];
const endIndex = Math.min(scene.endParagraphIndex, bodyParagraphs.items.length - 1);
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
for (let index = scene.startParagraphIndex; index <= endIndex; index += 1) {
const text = String(bodyParagraphs.items[index]?.text || "").trim();
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
continue;
}
texts.push(text);
}
return texts.join("\n");
});
return await captureCurrentSceneSnapshot();
};
const refreshDocumentStructure = async () => {
@ -3788,6 +3844,7 @@
const syncSceneProgress = async (options = {}) => {
const source = options.source || "manual";
const totalStartedAt = runtimePerformanceNow();
if (!resolvedSceneId) {
setSyncStatus("No resolved PlotDirector scene.");
return;
@ -3820,9 +3877,9 @@
sceneWordCountSyncInFlight = true;
sceneWordCountSyncPending = false;
let wordCount = detectedSceneWordCount;
let snapshot = options.snapshot || null;
try {
wordCount = await recountCachedCurrentScene();
snapshot = isSnapshotFresh(snapshot) ? snapshot : await getFreshCurrentSceneSnapshot();
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
@ -3831,6 +3888,14 @@
return;
}
if (!isSnapshotFresh(snapshot)) {
setSyncStatus("Waiting for typing to pause");
runtimeDebug("Scene sync skipped (stale snapshot).");
finishSceneWordCountSync();
return;
}
const wordCount = snapshot.wordCount;
if (!Number.isInteger(wordCount) || wordCount < 0) {
setSyncStatus("Sync failed");
runtimeDebug("Scene sync failed.");
@ -3838,7 +3903,7 @@
return;
}
if (lastSyncedSceneId === resolvedSceneId && lastSyncedWordCount === wordCount) {
if (lastSyncedSceneId === snapshot.sceneId && lastSyncedWordCount === wordCount) {
setSyncStatus("Synced");
runtimeDebug("Sync skipped (count unchanged).");
finishSceneWordCountSync();
@ -3855,16 +3920,22 @@
setSyncStatus("Syncing...");
runtimeDebug(`Scene sync started (${source}).`);
try {
const apiStartedAt = runtimePerformanceNow();
const response = await postJson("/api/word-companion/runtime/scene-word-count", {
documentGuid,
bookId: book.bookId,
chapterId: resolvedChapterId,
sceneId: resolvedSceneId,
documentGuid: snapshot.documentGuid || documentGuid,
bookId: snapshot.bookId || book.bookId,
chapterId: snapshot.chapterId,
sceneId: snapshot.sceneId,
wordCount
});
runtimeTiming(`Word count sync API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Scene sync result ignored (stale snapshot).");
return;
}
const actualWords = response?.actualWords ?? wordCount;
const syncedAt = new Date();
lastSyncedSceneId = resolvedSceneId;
lastSyncedSceneId = snapshot.sceneId;
lastSyncedWordCount = actualWords;
setText(syncPlotDirectorCount, displayValue(actualWords));
setText(plotDirectorActualWords, displayValue(actualWords));
@ -3872,6 +3943,7 @@
setText(runtimeLastSync, formatLastSynced(syncedAt));
setSyncStatus("Synced");
runtimeDebug("Scene sync succeeded.");
runtimeTiming(`Total word count sync: ${Math.round(runtimePerformanceNow() - totalStartedAt)}ms`);
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
@ -4106,34 +4178,62 @@
characterSuggestionInFlight = true;
characterSuggestionPending = false;
const totalStartedAt = runtimePerformanceNow();
try {
const [sceneText, characterAliases, assetAliases, locationAliases] = await Promise.all([
readCachedCurrentSceneText(),
const snapshot = await getFreshCurrentSceneSnapshot();
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Runtime suggestions skipped (stale snapshot).");
return;
}
const [characterAliases, assetAliases, locationAliases] = await Promise.all([
loadRuntimeCharacterAliases(),
loadRuntimeAssetAliases(),
loadRuntimeLocationAliases()
]);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Runtime suggestions skipped after cache load (stale snapshot).");
return;
}
const sceneText = snapshot.sceneText || "";
const characterStartedAt = runtimePerformanceNow();
const detected = detectCharactersInSceneText(sceneText, characterAliases);
runtimeTiming(`Character detection: ${Math.round(runtimePerformanceNow() - characterStartedAt)}ms`);
const characterIds = detected.map((item) => item.characterId);
const signature = characterIds.slice().sort((left, right) => left - right).join(",");
const assetStartedAt = runtimePerformanceNow();
const detectedAssets = detectAssetsInSceneText(sceneText, assetAliases);
runtimeTiming(`Asset detection: ${Math.round(runtimePerformanceNow() - assetStartedAt)}ms`);
const assetIds = detectedAssets.map((item) => item.assetId);
const assetSignature = assetIds.slice().sort((left, right) => left - right).join(",");
const locationStartedAt = runtimePerformanceNow();
const detectedLocations = detectLocationsInSceneText(sceneText, locationAliases);
runtimeTiming(`Location detection: ${Math.round(runtimePerformanceNow() - locationStartedAt)}ms`);
const locationIds = detectedLocations.map((item) => item.locationId);
const locationSignature = locationIds.slice().sort((left, right) => left - right).join(",");
if (!signature || (lastSuggestedSceneId === resolvedSceneId && lastSuggestedCharacterSignature === signature)) {
runtimeDebug("Character suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Character suggestions skipped before submit (stale snapshot).");
return;
}
const apiStartedAt = runtimePerformanceNow();
const response = await postJson("/api/word-companion/runtime/character-suggestions", {
documentGuid: currentDocumentGuid(),
sceneId: resolvedSceneId,
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
characterIds
});
runtimeTiming(`Character suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Character suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedSceneId = resolvedSceneId;
lastSuggestedSceneId = snapshot.sceneId;
lastSuggestedCharacterSignature = signature;
if ((response?.createdCount || 0) > 0) {
const names = detected.slice(0, 3).map((item) => item.name).join(", ");
@ -4146,14 +4246,24 @@
if (!assetSignature || (lastSuggestedAssetSceneId === resolvedSceneId && lastSuggestedAssetSignature === assetSignature)) {
runtimeDebug("Asset suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Asset suggestions skipped before submit (stale snapshot).");
return;
}
runtimeDebug(`Asset matches found: ${detectedAssets.map((item) => item.name).join(", ")}`);
const apiStartedAt = runtimePerformanceNow();
const assetResponse = await postJson("/api/word-companion/runtime/asset-suggestions", {
documentGuid: currentDocumentGuid(),
sceneId: resolvedSceneId,
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
assetIds
});
runtimeTiming(`Asset suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Asset suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedAssetSceneId = resolvedSceneId;
lastSuggestedAssetSceneId = snapshot.sceneId;
lastSuggestedAssetSignature = assetSignature;
if ((assetResponse?.createdCount || 0) > 0) {
const names = detectedAssets.slice(0, 3).map((item) => item.name).join(", ");
@ -4166,14 +4276,24 @@
if (!locationSignature || (lastSuggestedLocationSceneId === resolvedSceneId && lastSuggestedLocationSignature === locationSignature)) {
runtimeDebug("Location suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Location suggestions skipped before submit (stale snapshot).");
return;
}
runtimeDebug(`Location matches found: ${detectedLocations.map((item) => item.name).join(", ")}`);
const apiStartedAt = runtimePerformanceNow();
const locationResponse = await postJson("/api/word-companion/runtime/location-suggestions", {
documentGuid: currentDocumentGuid(),
sceneId: resolvedSceneId,
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
locationIds
});
runtimeTiming(`Location suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Location suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedLocationSceneId = resolvedSceneId;
lastSuggestedLocationSceneId = snapshot.sceneId;
lastSuggestedLocationSignature = locationSignature;
if ((locationResponse?.createdCount || 0) > 0) {
const names = detectedLocations.slice(0, 3).map((item) => item.name).join(", ");
@ -4182,6 +4302,7 @@
}
runtimeDebug(locationResponse?.message || "Location suggestions submitted.");
}
runtimeTiming(`Total suggestion pass: ${Math.round(runtimePerformanceNow() - totalStartedAt)}ms`);
} catch (error) {
console.error("Unable to detect runtime suggestions.", error);
setDiagnostics({ lastError: errorText(error) });
@ -4522,10 +4643,13 @@
runtimeDebug("Runtime update executed.");
try {
const selectionParagraphs = await window.Word.run(async (context) => await readSelectionParagraphs(context));
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, selectionParagraphs);
const changed = applyRuntimeSelection(selectedIndex);
await recountCachedCurrentScene();
const snapshot = await captureCurrentSceneSnapshot({ includeSelection: true });
if (!isSnapshotFresh(snapshot)) {
setSceneTrackingState("Waiting for typing to pause");
runtimeDebug("Runtime update skipped (stale snapshot).");
return;
}
const changed = !!snapshot.selectionChanged;
if (!changed) {
setSceneContextStale(false);
@ -4570,8 +4694,16 @@
const markRuntimeDirty = (reason = "selection", delayMs = runtimeIdleDelayMs) => {
runtimeSelectionDirty = true;
runtimeLastActivityAt = Date.now();
runtimeGeneration += 1;
lastCurrentSceneSnapshot = null;
clearPendingSceneWordCountSync();
clearPendingCharacterSuggestionDetection();
if (sceneWordCountSyncInFlight) {
sceneWordCountSyncPending = true;
}
if (characterSuggestionInFlight) {
characterSuggestionPending = true;
}
runtimeDebug(`${reason} changed.`);
if (isAutoRefreshingScene) {

File diff suppressed because one or more lines are too long