Phase 16P - Word Companion Performance Hardening and Office.js Snapshot Optimisation
This commit is contained in:
parent
524515e9b6
commit
db6f0992c1
@ -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);
|
||||
}
|
||||
|
||||
scene.wordCount = wordCount;
|
||||
setCurrentStructure(
|
||||
documentStructureCache.currentChapter?.title,
|
||||
scene.title,
|
||||
wordCount,
|
||||
documentStructureCache.currentChapter?.anchorId,
|
||||
scene.anchorId,
|
||||
documentStructureCache.currentChapter?.index
|
||||
);
|
||||
return wordCount;
|
||||
});
|
||||
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);
|
||||
|
||||
const readCachedCurrentSceneText = async () => {
|
||||
const scene = documentStructureCache?.currentScene;
|
||||
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
|
||||
return "";
|
||||
if (generation !== runtimeGeneration) {
|
||||
runtimeTiming(`Office snapshot: ${officeMs}ms stale`);
|
||||
return { stale: true, generation };
|
||||
}
|
||||
|
||||
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
||||
return "";
|
||||
let selectionChanged = false;
|
||||
if (includeSelection) {
|
||||
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, wordSnapshot.selectionParagraphs);
|
||||
selectionChanged = applyRuntimeSelection(selectedIndex);
|
||||
}
|
||||
|
||||
return await window.Word.run(async (context) => {
|
||||
const bodyParagraphs = context.document.body.paragraphs;
|
||||
bodyParagraphs.load("text");
|
||||
await context.sync();
|
||||
const currentSceneItem = documentStructureCache?.currentScene;
|
||||
if (!currentSceneItem || !Number.isInteger(currentSceneItem.startParagraphIndex) || !Number.isInteger(currentSceneItem.endParagraphIndex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
for (let index = scene.startParagraphIndex; index <= endIndex; index += 1) {
|
||||
const text = String(bodyParagraphs.items[index]?.text || "").trim();
|
||||
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);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user