mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
perf (chat): Optimize loadItemizedPrompts() to prevent resident memory usage.
This commit is contained in:
parent
a02626ebbd
commit
d738355f7c
4 changed files with 388 additions and 73 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
tests/itemized-prompts-contract.test.mjs
Normal file
44
tests/itemized-prompts-contract.test.mjs
Normal 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\)/);
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue