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

File diff suppressed because one or more lines are too long