perf (chat): Optimize loadItemizedPrompts() to prevent resident memory usage.

This commit is contained in:
Darkatse 2026-04-05 14:52:23 -04:00
parent a02626ebbd
commit d738355f7c
4 changed files with 388 additions and 73 deletions

View file

@ -28,3 +28,37 @@
- `src/scripts/tokenizers.js`:`loadTokenCache()` / `saveTokenCache()` / `resetTokenCache()` / `tokenCacheState` / `initTokenizers()` 事件挂载
---
## ItemizedPrompts(Prompt Inspector)避免 chat-open whole-load
### 解决的问题
历史实现会在打开聊天时执行 `loadItemizedPrompts(getCurrentChatId())`,并从 IndexedDB 一次性加载 `chatId -> itemizedPrompts[]` 整包数组。
该数组的单条 record 内含大量长字符串(`rawPrompt/finalPrompt/worldInfo/...`),体积通常远大于聊天正文,导致移动端 WebView renderer 常驻集显著膨胀。
### 当前实现(契约)
- **存储形态(Index + Record)**:
- Index:`tt_prompts_index:${chatId}` → `Array<{ mesId, recordId }>`
- Record:`tt_prompts_record:${chatId}:${recordId}` → 单条 prompt artifacts(原 record 全量)
- **打开聊天只加载 Index**:`loadItemizedPrompts(chatId)` 仅恢复 `mesId -> recordId` 映射,不加载任何 record。
- **点击才加载 Record**:`.mes_prompt` 点击后,`promptItemize()` 按 `mesId` 懒加载对应 record(以及可选的上一条 record 用于 diff)并渲染 Inspector。
- **生成链路不再常驻 records**:生成结束写入 record + 更新 index,不再把 record `push` 到全局数组里常驻。
- **消息编辑语义保持**:
- `swapItemizedPrompts()`:仅交换 index 的 `mesId` 映射
- `deleteItemizedPromptForMessage()`:按上游语义 shift 后续 `mesId`,并删除被删除消息的 record
- **删除/清理覆盖完整**:
- chat 删除会清理 Index + Records(不留磁盘残留)
- `clearPrompts` 会清空整个实例
### 兼容性边界
- 仍复用 `localforage` 实例名 `SillyTavern_Prompts`,保证与上游/生态对存储实例名的假设一致。
- legacy 格式(`chatId -> array`)在首次进入该 chat 且 index 不存在时,会被迁移为 Index + Records 并删除旧 key(一次性成本,后续打开聊天不再 whole-load)。
### 关键代码位置
- `src/scripts/itemized-prompts.js`:Index/Record keys、加载/迁移、Inspector 懒加载、swap/delete 语义
- `src/script.js`:生成写入、按钮显示(index presence)

View file

@ -319,7 +319,7 @@ import { extractReasoningFromData, extractReasoningSignatureFromData, initReason
import { accountStorage } from './scripts/util/AccountStorage.js';
import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
import { initDataMaid } from './scripts/data-maid.js';
import { clearItemizedPrompts, deleteItemizedPromptForMessage, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts, swapItemizedPrompts } from './scripts/itemized-prompts.js';
import { clearItemizedPrompts, deleteItemizedPromptForMessage, deleteItemizedPrompts, findItemizedPromptSet, hasItemizedPromptForMessage, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts, swapItemizedPrompts, unloadItemizedPrompts, upsertItemizedPromptRecord } from './scripts/itemized-prompts.js';
import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
import { event_types, eventSource } from './scripts/events.js';
import { initAccessibility } from './scripts/a11y.js';
@ -1901,7 +1901,7 @@ export async function clearChat({ clearData = false } = {}) {
} else { console.debug('saw no avatars'); }
await saveItemizedPrompts(getCurrentChatId());
itemizedPrompts.length = 0;
unloadItemizedPrompts();
if (clearData) chat.length = 0;
}
@ -2803,12 +2803,8 @@ export function addCopyToCodeBlocks(messageElement) {
* @return {void}
*/
function updateMessageItemizedPromptButton(message, { messageId = chat.indexOf(message), messageElement = chatElement.find(`.mes[mesid="${messageId}"]`) }) {
//if we have itemized messages, and the array isn't null..
if (!message.is_user && Array.isArray(itemizedPrompts) && itemizedPrompts.length > 0) {
const itemizedPrompt = itemizedPrompts.find(x => Number(x.mesId) === Number(messageId));
if (itemizedPrompt) {
messageElement.find('.mes_prompt').show();
}
if (!message.is_user && hasItemizedPromptForMessage(messageId)) {
messageElement.find('.mes_prompt').show();
}
}
@ -5687,17 +5683,7 @@ async function GenerateInternal(type, { automatic_trigger, force_name2, quiet_pr
examplesCount: main_api !== 'openai' ? (pinExmString ? mesExamplesArray.length : count_exm_add) : oaiMessageExamples.length,
};
//console.log(additionalPromptStuff);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === additionalPromptStuff.mesId);
if (itemizedIndex !== -1) {
itemizedPrompts[itemizedIndex] = additionalPromptStuff;
}
else {
itemizedPrompts.push(additionalPromptStuff);
}
console.debug(`pushed prompt bits to itemizedPrompts array. Length is now: ${itemizedPrompts.length}`);
upsertItemizedPromptRecord(additionalPromptStuff);
if (isStreamingEnabled() && type !== 'quiet') {
continue_mag = promptReasoning.removePrefix(continue_mag);

View file

@ -7,13 +7,145 @@ import { power_user, registerDebugFunction } from './power-user.js';
import { isMobile } from './RossAscends-mods.js';
import { renderTemplateAsync } from './templates.js';
import { getFriendlyTokenizerName, getTokenCountAsync } from './tokenizers.js';
import { copyText } from './utils.js';
import { copyText, uuidv4 } from './utils.js';
let PromptArrayItemForRawPromptDisplay;
let priorPromptArrayItemForRawPromptDisplay;
// Keep the upstream storage namespace so legacy SillyTavern itemized prompts can be migrated in-place.
const promptStorage = localforage.createInstance({ name: 'SillyTavern_Prompts' });
export let itemizedPrompts = [];
// Storage contract (TauriTavern):
// - `tt_prompts_index:<chatId>` stores a lightweight sorted index of { mesId, recordId }.
// - `tt_prompts_record:<chatId>:<recordId>` stores the full prompt record, loaded on-demand (e.g. inspector click).
// Legacy SillyTavern stores a full array under `<chatId>`; we migrate once and delete it to avoid cold-start bloat.
const TT_PROMPTS_INDEX_PREFIX = 'tt_prompts_index:';
const TT_PROMPTS_RECORD_PREFIX = 'tt_prompts_record:';
/**
* @typedef {{ mesId: number, recordId: string }} ItemizedPromptIndexEntry
*/
// Historical name kept for upstream compatibility: this holds index entries (not full records) for the active chat.
export let itemizedPrompts = /** @type {ItemizedPromptIndexEntry[]} */ ([]);
/** @type {string|null} */
let activeChatId = null;
/** @type {Map<number, string>} */
let recordIdByMesId = new Map();
/** @type {Promise<unknown>} */
// Serialize index writes to keep localforage updates ordered when multiple upserts happen rapidly.
let indexWriteChain = Promise.resolve();
function getIndexKey(chatId) {
return `${TT_PROMPTS_INDEX_PREFIX}${chatId}`;
}
function getRecordKey(chatId, recordId) {
return `${TT_PROMPTS_RECORD_PREFIX}${chatId}:${recordId}`;
}
function getRecordKeyPrefix(chatId) {
return `${TT_PROMPTS_RECORD_PREFIX}${chatId}:`;
}
function rebuildRecordIdMap(entries) {
recordIdByMesId = new Map(entries.map((entry) => [Number(entry.mesId), entry.recordId]));
}
function setActiveIndex(chatId, entries) {
activeChatId = chatId;
itemizedPrompts = entries;
itemizedPrompts.sort((a, b) => a.mesId - b.mesId);
rebuildRecordIdMap(itemizedPrompts);
}
function clearActiveIndex() {
activeChatId = null;
itemizedPrompts = [];
recordIdByMesId = new Map();
}
function enqueueIndexWrite(chatId, entriesSnapshot) {
const snapshot = entriesSnapshot.map((entry) => ({ mesId: entry.mesId, recordId: entry.recordId }));
const key = getIndexKey(chatId);
const write = indexWriteChain.then(() => promptStorage.setItem(key, snapshot));
indexWriteChain = write.catch((error) => {
console.error('Error saving itemized prompts index', { chatId }, error);
});
return write;
}
async function loadPromptIndex(chatId) {
const value = await promptStorage.getItem(getIndexKey(chatId));
if (!value) {
return null;
}
if (!Array.isArray(value)) {
throw new Error(`Invalid tt_prompts_index for chatId: ${chatId}`);
}
return /** @type {ItemizedPromptIndexEntry[]} */ (value);
}
async function migrateLegacyPrompts(chatId) {
// One-time migration: convert legacy per-chat array into (index + per-record) storage.
// This still reads the legacy array once; subsequent loads only pull the small index.
const legacy = await promptStorage.getItem(chatId);
if (!legacy) {
await enqueueIndexWrite(chatId, []);
return [];
}
if (!Array.isArray(legacy)) {
throw new Error(`Invalid legacy itemized prompts for chatId: ${chatId}`);
}
/** @type {ItemizedPromptIndexEntry[]} */
const entries = [];
const seen = new Set();
for (let i = legacy.length - 1; i >= 0; i -= 1) {
const record = legacy[i];
const mesId = Number(record?.mesId);
if (!Number.isFinite(mesId)) {
throw new Error(`Invalid legacy itemized prompt: mesId missing for chatId: ${chatId}`);
}
if (seen.has(mesId)) {
continue;
}
seen.add(mesId);
const recordId = uuidv4();
await promptStorage.setItem(getRecordKey(chatId, recordId), record);
entries.push({ mesId, recordId });
}
entries.sort((a, b) => a.mesId - b.mesId);
await enqueueIndexWrite(chatId, entries);
await promptStorage.removeItem(chatId);
return entries;
}
async function clonePromptStore(sourceChatId, targetChatId) {
// Used by upstream flows that "save" prompts under a different chatId (e.g. clones/branches).
// We keep recordIds stable and copy records so the target chat can lazily load them later.
const targetRecordPrefix = getRecordKeyPrefix(targetChatId);
const keys = await promptStorage.keys();
await Promise.all(
keys
.filter((key) => key === targetChatId || key === getIndexKey(targetChatId) || key.startsWith(targetRecordPrefix))
.map((key) => promptStorage.removeItem(key)),
);
const entriesSnapshot = itemizedPrompts.map((entry) => ({ mesId: entry.mesId, recordId: entry.recordId }));
for (const entry of entriesSnapshot) {
const record = await promptStorage.getItem(getRecordKey(sourceChatId, entry.recordId));
if (record) {
await promptStorage.setItem(getRecordKey(targetChatId, entry.recordId), record);
}
}
await promptStorage.setItem(getIndexKey(targetChatId), entriesSnapshot);
}
/**
* Gets the itemized prompts for a chat.
@ -22,18 +154,21 @@ export let itemizedPrompts = [];
export async function loadItemizedPrompts(chatId) {
try {
if (!chatId) {
itemizedPrompts = [];
clearActiveIndex();
return;
}
itemizedPrompts = await promptStorage.getItem(chatId);
if (!itemizedPrompts) {
itemizedPrompts = [];
const index = await loadPromptIndex(chatId);
if (index) {
setActiveIndex(chatId, index);
return;
}
} catch {
console.log('Error loading itemized prompts for chat', chatId);
itemizedPrompts = [];
const migrated = await migrateLegacyPrompts(chatId);
setActiveIndex(chatId, migrated);
} catch (error) {
console.error('Error loading itemized prompts for chat', { chatId }, error);
clearActiveIndex();
}
}
@ -47,9 +182,18 @@ export async function saveItemizedPrompts(chatId) {
return;
}
await promptStorage.setItem(chatId, itemizedPrompts);
} catch {
console.log('Error saving itemized prompts for chat', chatId);
if (!activeChatId) {
return;
}
if (chatId === activeChatId) {
await enqueueIndexWrite(activeChatId, itemizedPrompts);
return;
}
await clonePromptStore(activeChatId, chatId);
} catch (error) {
console.error('Error saving itemized prompts for chat', { chatId }, error);
}
}
@ -60,17 +204,22 @@ export async function saveItemizedPrompts(chatId) {
* @returns
*/
export async function replaceItemizedPromptText(mesId, promptText) {
if (!Array.isArray(itemizedPrompts)) {
itemizedPrompts = [];
}
const itemizedPrompt = itemizedPrompts.find(x => x.mesId === mesId);
if (!itemizedPrompt) {
if (!activeChatId) {
return;
}
itemizedPrompt.rawPrompt = promptText;
const recordId = recordIdByMesId.get(Number(mesId));
if (!recordId) {
return;
}
const record = await promptStorage.getItem(getRecordKey(activeChatId, recordId));
if (!record) {
return;
}
record.rawPrompt = promptText;
await promptStorage.setItem(getRecordKey(activeChatId, recordId), record);
}
/**
@ -83,9 +232,19 @@ export async function deleteItemizedPrompts(chatId) {
return;
}
await promptStorage.removeItem(chatId);
} catch {
console.log('Error deleting itemized prompts for chat', chatId);
const recordPrefix = getRecordKeyPrefix(chatId);
const keys = await promptStorage.keys();
await Promise.all(
keys
.filter((key) => key === chatId || key === getIndexKey(chatId) || key.startsWith(recordPrefix))
.map((key) => promptStorage.removeItem(key)),
);
if (activeChatId === chatId) {
clearActiveIndex();
}
} catch (error) {
console.error('Error deleting itemized prompts for chat', { chatId }, error);
}
}
@ -95,12 +254,48 @@ export async function deleteItemizedPrompts(chatId) {
export async function clearItemizedPrompts() {
try {
await promptStorage.clear();
itemizedPrompts = [];
} catch {
console.log('Error clearing itemized prompts');
clearActiveIndex();
} catch (error) {
console.error('Error clearing itemized prompts', error);
}
}
export function unloadItemizedPrompts() {
// Only clears in-memory state; persistent storage is untouched.
clearActiveIndex();
}
export function hasItemizedPromptForMessage(mesId) {
// Fast path for UI: avoid loading any record data just to decide whether to render a button.
return recordIdByMesId.has(Number(mesId));
}
export function upsertItemizedPromptRecord(record) {
if (!activeChatId) {
return;
}
const chatId = activeChatId;
const mesId = Number(record?.mesId);
if (!Number.isFinite(mesId)) {
throw new Error('Cannot upsert itemized prompt record: mesId missing');
}
let recordId = recordIdByMesId.get(mesId);
if (!recordId) {
recordId = uuidv4();
itemizedPrompts.push({ mesId, recordId });
itemizedPrompts.sort((a, b) => a.mesId - b.mesId);
rebuildRecordIdMap(itemizedPrompts);
}
void promptStorage.setItem(getRecordKey(chatId, recordId), record).catch((error) => {
console.error('Error saving itemized prompt record', { chatId, mesId, recordId }, error);
});
void enqueueIndexWrite(chatId, itemizedPrompts);
}
export async function itemizedParams(itemizedPrompts, thisPromptSet, incomingMesId) {
const params = {
charDescriptionTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].charDescription),
@ -240,19 +435,19 @@ export function findItemizedPromptSet(itemizedPrompts, incomingMesId) {
return thisPromptSet;
}
export async function promptItemize(itemizedPrompts, requestedMesId) {
async function promptItemizeRecords(promptRecords, requestedMesId) {
console.log('PROMPT ITEMIZE ENTERED');
var incomingMesId = Number(requestedMesId);
console.debug(`looking for MesId ${incomingMesId}`);
var thisPromptSet = findItemizedPromptSet(itemizedPrompts, incomingMesId);
var thisPromptSet = findItemizedPromptSet(promptRecords, incomingMesId);
if (thisPromptSet === undefined) {
console.log(`couldnt find the right mesId. looked for ${incomingMesId}`);
console.log(itemizedPrompts);
console.log(promptRecords);
return null;
}
const params = await itemizedParams(itemizedPrompts, thisPromptSet, incomingMesId);
const params = await itemizedParams(promptRecords, thisPromptSet, incomingMesId);
const flatten = (rawPrompt) => Array.isArray(rawPrompt) ? rawPrompt.map(x => x.content).join('\n') : rawPrompt;
const template = params.this_main_api == 'openai'
@ -267,8 +462,8 @@ export async function promptItemize(itemizedPrompts, requestedMesId) {
diffPrevPrompt.style.display = '';
diffPrevPrompt.addEventListener('click', function () {
const dmp = new DiffMatchPatch();
const text1 = flatten(itemizedPrompts[priorPromptArrayItemForRawPromptDisplay].rawPrompt);
const text2 = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
const text1 = flatten(promptRecords[priorPromptArrayItemForRawPromptDisplay].rawPrompt);
const text2 = flatten(promptRecords[PromptArrayItemForRawPromptDisplay].rawPrompt);
dmp.Diff_Timeout = 2.0;
@ -288,7 +483,7 @@ export async function promptItemize(itemizedPrompts, requestedMesId) {
diffPrevPrompt.style.display = 'none';
}
popup.dlg.querySelector('#copyPromptToClipboard').addEventListener('pointerup', async function () {
let rawPrompt = itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt;
let rawPrompt = promptRecords[PromptArrayItemForRawPromptDisplay].rawPrompt;
let rawPromptValues = rawPrompt;
if (Array.isArray(rawPrompt)) {
@ -300,12 +495,12 @@ export async function promptItemize(itemizedPrompts, requestedMesId) {
});
popup.dlg.querySelector('#showRawPrompt').addEventListener('click', async function () {
//console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
//console.log(promptRecords[PromptArrayItemForRawPromptDisplay].rawPrompt);
console.log(PromptArrayItemForRawPromptDisplay);
console.log(itemizedPrompts);
console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
console.log(promptRecords);
console.log(promptRecords[PromptArrayItemForRawPromptDisplay].rawPrompt);
const rawPrompt = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt);
const rawPrompt = flatten(promptRecords[PromptArrayItemForRawPromptDisplay].rawPrompt);
// Mobile needs special handholding. The side-view on the popup wouldn't work,
// so we just show an additional popup for this.
@ -327,6 +522,48 @@ export async function promptItemize(itemizedPrompts, requestedMesId) {
await popup.show();
}
export async function promptItemize(itemizedPromptsOrRecords, requestedMesId) {
// Backward-compatible entry point:
// - Upstream passes an array of records (with rawPrompt/finalPrompt); we render directly.
// - TauriTavern passes the index array and lazily loads only the clicked record(s).
const first = Array.isArray(itemizedPromptsOrRecords) ? itemizedPromptsOrRecords[0] : null;
const looksLikeRecordArray = Boolean(first && typeof first === 'object' && ('rawPrompt' in first || 'finalPrompt' in first));
if (looksLikeRecordArray) {
return await promptItemizeRecords(itemizedPromptsOrRecords, requestedMesId);
}
if (!activeChatId) {
return null;
}
const incomingMesId = Number(requestedMesId);
if (!Number.isFinite(incomingMesId)) {
return null;
}
const recordId = recordIdByMesId.get(incomingMesId);
if (!recordId) {
return null;
}
const record = await promptStorage.getItem(getRecordKey(activeChatId, recordId));
if (!record) {
return null;
}
let priorRecord = null;
for (let i = itemizedPrompts.length - 1; i >= 0; i -= 1) {
const entry = itemizedPrompts[i];
if (entry.mesId < incomingMesId) {
priorRecord = await promptStorage.getItem(getRecordKey(activeChatId, entry.recordId));
break;
}
}
const recordsForInspector = priorRecord ? [priorRecord, record] : [record];
return await promptItemizeRecords(recordsForInspector, incomingMesId);
}
export function initItemizedPrompts() {
registerDebugFunction('clearPrompts', 'Delete itemized prompts', 'Deletes all itemized prompts from the local storage.', async () => {
await clearItemizedPrompts();
@ -358,22 +595,21 @@ export function initItemizedPrompts() {
* @param {number} targetMessageId Target message ID
*/
export function swapItemizedPrompts(sourceMessageId, targetMessageId) {
if (!Array.isArray(itemizedPrompts)) {
if (!activeChatId) {
return;
}
const sourcePrompts = itemizedPrompts.filter(x => x.mesId === sourceMessageId);
const targetPrompts = itemizedPrompts.filter(x => x.mesId === targetMessageId);
sourcePrompts.forEach(prompt => {
prompt.mesId = targetMessageId;
});
targetPrompts.forEach(prompt => {
prompt.mesId = sourceMessageId;
});
const chatId = activeChatId;
const sourceEntry = itemizedPrompts.find((x) => x.mesId === sourceMessageId);
const targetEntry = itemizedPrompts.find((x) => x.mesId === targetMessageId);
if (!sourceEntry && !targetEntry) {
return;
}
sourceEntry && (sourceEntry.mesId = targetMessageId);
targetEntry && (targetEntry.mesId = sourceMessageId);
itemizedPrompts.sort((a, b) => a.mesId - b.mesId);
rebuildRecordIdMap(itemizedPrompts);
void enqueueIndexWrite(chatId, itemizedPrompts);
}
/**
@ -382,13 +618,28 @@ export function swapItemizedPrompts(sourceMessageId, targetMessageId) {
* @param {number} messageId Message ID to delete itemized prompt for
*/
export function deleteItemizedPromptForMessage(messageId) {
if (!Array.isArray(itemizedPrompts)) {
if (!activeChatId) {
return;
}
itemizedPrompts = itemizedPrompts.filter(x => x.mesId !== messageId);
const chatId = activeChatId;
const mesId = Number(messageId);
const deletedRecordId = recordIdByMesId.get(mesId) ?? null;
for (const prompt of itemizedPrompts.filter(x => x.mesId > messageId)) {
prompt.mesId -= 1;
itemizedPrompts = itemizedPrompts.filter((x) => x.mesId !== mesId);
for (const entry of itemizedPrompts) {
if (entry.mesId > mesId) {
entry.mesId -= 1;
}
}
itemizedPrompts.sort((a, b) => a.mesId - b.mesId);
rebuildRecordIdMap(itemizedPrompts);
void enqueueIndexWrite(chatId, itemizedPrompts);
if (deletedRecordId) {
void promptStorage.removeItem(getRecordKey(chatId, deletedRecordId)).catch((error) => {
console.error('Error deleting itemized prompt record', { chatId, mesId, recordId: deletedRecordId }, error);
});
}
}

View file

@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function extractBetween(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
assert.notEqual(start, -1, `Missing marker: ${startMarker}`);
const end = source.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, `Missing marker: ${endMarker}`);
return source.slice(start, end);
}
test('Itemized prompts use index+record schema and avoid chat-open whole-load', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/scripts/itemized-prompts.js'), 'utf8');
assert.match(source, /tt_prompts_index:/);
assert.match(source, /tt_prompts_record:/);
const loadFn = extractBetween(
source,
'export async function loadItemizedPrompts(chatId) {',
'export async function saveItemizedPrompts(chatId) {',
);
assert.match(loadFn, /await loadPromptIndex\(chatId\);/);
assert.match(loadFn, /await migrateLegacyPrompts\(chatId\);/);
assert.doesNotMatch(loadFn, /promptStorage\.getItem\(chatId\)/);
assert.match(source, /async function migrateLegacyPrompts\(chatId\) \{[\s\S]*promptStorage\.getItem\(chatId\)/);
});
test('Chat rendering and generation rely on index presence, not whole records', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
assert.match(source, /hasItemizedPromptForMessage\(messageId\)/);
assert.match(source, /upsertItemizedPromptRecord\(additionalPromptStuff\)/);
assert.doesNotMatch(source, /itemizedPrompts\.push\(additionalPromptStuff\)/);
});