mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
fix (windowed payload): Restrict frontend windowed payload contract to a single flight
This commit is contained in:
parent
e459e827cb
commit
f786d68c49
8 changed files with 687 additions and 131 deletions
242
src/script.js
242
src/script.js
|
|
@ -29,6 +29,8 @@ import {
|
||||||
clearWindowedChatState,
|
clearWindowedChatState,
|
||||||
DEFAULT_CHAT_WINDOW_LINES,
|
DEFAULT_CHAT_WINDOW_LINES,
|
||||||
getWindowedChatState,
|
getWindowedChatState,
|
||||||
|
getWindowedChatKey,
|
||||||
|
mergeWindowedChatCursorOffset,
|
||||||
setWindowedChatState,
|
setWindowedChatState,
|
||||||
shiftWindowedMessageSaveState,
|
shiftWindowedMessageSaveState,
|
||||||
} from './scripts/tauri/chat/windowed-state.js';
|
} from './scripts/tauri/chat/windowed-state.js';
|
||||||
|
|
@ -1631,6 +1633,9 @@ export async function replaceCurrentChat() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {{ state: any, promise: Promise<void> } | null} */
|
||||||
|
let windowedShowMoreMessagesPending = null;
|
||||||
|
|
||||||
export async function showMoreMessages(messagesToLoad = null) {
|
export async function showMoreMessages(messagesToLoad = null) {
|
||||||
const windowState = getWindowedChatState();
|
const windowState = getWindowedChatState();
|
||||||
if (windowState && isTauriChatPayloadTransportEnabled()) {
|
if (windowState && isTauriChatPayloadTransportEnabled()) {
|
||||||
|
|
@ -1638,78 +1643,100 @@ export async function showMoreMessages(messagesToLoad = null) {
|
||||||
throw new Error('Windowed chat cursor is missing');
|
throw new Error('Windowed chat cursor is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingPending = windowedShowMoreMessagesPending;
|
||||||
|
if (existingPending?.state === windowState) {
|
||||||
|
return existingPending.promise;
|
||||||
|
}
|
||||||
|
|
||||||
const count = Number(messagesToLoad || DEFAULT_CHAT_WINDOW_LINES);
|
const count = Number(messagesToLoad || DEFAULT_CHAT_WINDOW_LINES);
|
||||||
const prevHeight = chatElement.prop('scrollHeight');
|
const prevHeight = chatElement.prop('scrollHeight');
|
||||||
const showMoreButton = $('#show_more_messages');
|
const showMoreButton = $('#show_more_messages');
|
||||||
const isButtonInView = showMoreButton[0] && isElementInViewport(showMoreButton[0]);
|
const isButtonInView = showMoreButton[0] && isElementInViewport(showMoreButton[0]);
|
||||||
|
|
||||||
const result = windowState.kind === 'group'
|
const run = (async () => {
|
||||||
? await loadGroupChatPayloadBefore({
|
const result = windowState.kind === 'group'
|
||||||
id: windowState.id,
|
? await loadGroupChatPayloadBefore({
|
||||||
cursor: windowState.cursor,
|
id: windowState.id,
|
||||||
maxLines: count,
|
cursor: windowState.cursor,
|
||||||
})
|
maxLines: count,
|
||||||
: await loadCharacterChatPayloadBefore({
|
})
|
||||||
characterName: windowState.characterName,
|
: await loadCharacterChatPayloadBefore({
|
||||||
avatarUrl: windowState.avatarUrl,
|
characterName: windowState.characterName,
|
||||||
fileName: windowState.fileName,
|
avatarUrl: windowState.avatarUrl,
|
||||||
cursor: windowState.cursor,
|
fileName: windowState.fileName,
|
||||||
maxLines: count,
|
cursor: windowState.cursor,
|
||||||
});
|
maxLines: count,
|
||||||
|
});
|
||||||
|
|
||||||
const messages = result?.messages;
|
if (getWindowedChatState() !== windowState) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) {
|
return;
|
||||||
showMoreButton.remove();
|
}
|
||||||
|
|
||||||
|
const messages = result?.messages;
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
showMoreButton.remove();
|
||||||
|
setWindowedChatState({
|
||||||
|
...windowState,
|
||||||
|
cursor: result?.cursor ?? windowState.cursor,
|
||||||
|
hasMoreBefore: false,
|
||||||
|
});
|
||||||
|
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.forEach(ensureMessageMediaIsArray);
|
||||||
|
chat.splice(0, 0, ...messages);
|
||||||
|
if (this_edit_mes_id >= 0) {
|
||||||
|
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let id = 0; id < messages.length; id += 1) {
|
||||||
|
const messageElement = updateMessageElement(chat[id], { messageId: id });
|
||||||
|
fragment.appendChild(messageElement[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMoreButton[0]) {
|
||||||
|
showMoreButton[0].after(fragment);
|
||||||
|
} else {
|
||||||
|
chatElement[0].prepend(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateViewMessageIds(0);
|
||||||
|
refreshSwipeButtons();
|
||||||
|
|
||||||
|
const hasMoreBefore = Boolean(result?.hasMoreBefore);
|
||||||
|
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
|
||||||
setWindowedChatState({
|
setWindowedChatState({
|
||||||
...windowState,
|
...shiftedState,
|
||||||
cursor: result?.cursor ?? windowState.cursor,
|
cursor: result.cursor,
|
||||||
hasMoreBefore: false,
|
hasMoreBefore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasMoreBefore) {
|
||||||
|
showMoreButton.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isButtonInView) {
|
||||||
|
const newHeight = chatElement.prop('scrollHeight');
|
||||||
|
chatElement.scrollTop(newHeight - prevHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyStylePins();
|
||||||
|
applyCharacterTagsToMessageDivs();
|
||||||
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
||||||
return;
|
})();
|
||||||
|
|
||||||
|
windowedShowMoreMessagesPending = { state: windowState, promise: run };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run;
|
||||||
|
} finally {
|
||||||
|
if (windowedShowMoreMessagesPending?.promise === run) {
|
||||||
|
windowedShowMoreMessagesPending = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.forEach(ensureMessageMediaIsArray);
|
|
||||||
chat.splice(0, 0, ...messages);
|
|
||||||
if (this_edit_mes_id >= 0) {
|
|
||||||
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let id = 0; id < messages.length; id += 1) {
|
|
||||||
const messageElement = updateMessageElement(chat[id], { messageId: id });
|
|
||||||
fragment.appendChild(messageElement[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMoreButton[0]) {
|
|
||||||
showMoreButton[0].after(fragment);
|
|
||||||
} else {
|
|
||||||
chatElement[0].prepend(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateViewMessageIds(0);
|
|
||||||
refreshSwipeButtons();
|
|
||||||
|
|
||||||
const hasMoreBefore = Boolean(result?.hasMoreBefore);
|
|
||||||
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
|
|
||||||
setWindowedChatState({
|
|
||||||
...shiftedState,
|
|
||||||
cursor: result.cursor,
|
|
||||||
hasMoreBefore,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasMoreBefore) {
|
|
||||||
showMoreButton.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isButtonInView) {
|
|
||||||
const newHeight = chatElement.prop('scrollHeight');
|
|
||||||
chatElement.scrollTop(newHeight - prevHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyStylePins();
|
|
||||||
applyCharacterTagsToMessageDivs();
|
|
||||||
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1887,6 +1914,9 @@ export function cancelDebouncedChatSave() {
|
||||||
export async function clearChat({ clearData = false } = {}) {
|
export async function clearChat({ clearData = false } = {}) {
|
||||||
cancelDebouncedChatSave();
|
cancelDebouncedChatSave();
|
||||||
cancelDebouncedMetadataSave();
|
cancelDebouncedMetadataSave();
|
||||||
|
if (clearData) {
|
||||||
|
clearWindowedChatState();
|
||||||
|
}
|
||||||
closeMessageEditor();
|
closeMessageEditor();
|
||||||
getCodeHighlightCoordinator().reset();
|
getCodeHighlightCoordinator().reset();
|
||||||
extension_prompts = {};
|
extension_prompts = {};
|
||||||
|
|
@ -7719,15 +7749,17 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
||||||
String(error?.code || '').toLowerCase() === 'integrity'
|
String(error?.code || '').toLowerCase() === 'integrity'
|
||||||
|| /integrity/i.test(String(error?.message || ''));
|
|| /integrity/i.test(String(error?.message || ''));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isTauriChatPayloadTransportEnabled()) {
|
if (isTauriChatPayloadTransportEnabled()) {
|
||||||
const windowState = getWindowedChatState();
|
const windowState = getWindowedChatState();
|
||||||
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
|
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
|
||||||
const {
|
const expectedWindowKey = getWindowedChatKey(windowState);
|
||||||
patch,
|
const expectedCursorOffset = windowState.cursor.offset;
|
||||||
savedMessageCount: nextSavedMessageCount,
|
const {
|
||||||
dirtyFromIndex: nextDirtyFromIndex,
|
patch,
|
||||||
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
|
savedMessageCount: nextSavedMessageCount,
|
||||||
|
dirtyFromIndex: nextDirtyFromIndex,
|
||||||
|
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
|
||||||
|
|
||||||
const cursor = await patchCharacterChatPayloadWindowed({
|
const cursor = await patchCharacterChatPayloadWindowed({
|
||||||
characterName: characters[this_chid].name,
|
characterName: characters[this_chid].name,
|
||||||
|
|
@ -7735,21 +7767,30 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
cursor: windowState.cursor,
|
cursor: windowState.cursor,
|
||||||
header: JSON.stringify(chatHeader),
|
header: JSON.stringify(chatHeader),
|
||||||
patch,
|
patch,
|
||||||
force: Boolean(force),
|
force: Boolean(force),
|
||||||
});
|
});
|
||||||
|
|
||||||
setWindowedChatState({
|
const activeWindowState = getWindowedChatState();
|
||||||
...windowState,
|
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
|
||||||
cursor,
|
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
|
||||||
savedMessageCount: nextSavedMessageCount,
|
const nextWindowState = {
|
||||||
dirtyFromIndex: nextDirtyFromIndex,
|
...activeWindowState,
|
||||||
});
|
cursor: mergedCursor,
|
||||||
} else {
|
};
|
||||||
await saveCharacterChatPayload({
|
|
||||||
characterName: characters[this_chid].name,
|
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
|
||||||
avatarUrl: characters[this_chid].avatar,
|
nextWindowState.savedMessageCount = nextSavedMessageCount;
|
||||||
fileName: fileName,
|
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWindowedChatState(nextWindowState);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await saveCharacterChatPayload({
|
||||||
|
characterName: characters[this_chid].name,
|
||||||
|
avatarUrl: characters[this_chid].avatar,
|
||||||
|
fileName: fileName,
|
||||||
payload,
|
payload,
|
||||||
force: Boolean(force),
|
force: Boolean(force),
|
||||||
});
|
});
|
||||||
|
|
@ -7975,8 +8016,13 @@ export async function unshallowCharacter(characterId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChat() {
|
export async function getChat() {
|
||||||
|
const startedChid = this_chid;
|
||||||
|
const startedSelectedGroup = selected_group;
|
||||||
|
const startedCharacter = startedChid !== undefined ? characters[startedChid] : null;
|
||||||
|
const startedChatFile = startedCharacter?.chat;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unshallowCharacter(this_chid);
|
await unshallowCharacter(startedChid);
|
||||||
const usePayloadTransport = isTauriChatPayloadTransportEnabled();
|
const usePayloadTransport = isTauriChatPayloadTransportEnabled();
|
||||||
let data;
|
let data;
|
||||||
let windowedCursor = null;
|
let windowedCursor = null;
|
||||||
|
|
@ -7984,9 +8030,9 @@ export async function getChat() {
|
||||||
|
|
||||||
if (usePayloadTransport) {
|
if (usePayloadTransport) {
|
||||||
const window = await loadCharacterChatPayloadTail({
|
const window = await loadCharacterChatPayloadTail({
|
||||||
characterName: characters[this_chid].name,
|
characterName: startedCharacter?.name,
|
||||||
avatarUrl: characters[this_chid].avatar,
|
avatarUrl: startedCharacter?.avatar,
|
||||||
fileName: characters[this_chid].chat,
|
fileName: startedChatFile,
|
||||||
maxLines: DEFAULT_CHAT_WINDOW_LINES,
|
maxLines: DEFAULT_CHAT_WINDOW_LINES,
|
||||||
allowNotFound: true,
|
allowNotFound: true,
|
||||||
});
|
});
|
||||||
|
|
@ -8001,9 +8047,9 @@ export async function getChat() {
|
||||||
headers: getRequestHeaders(),
|
headers: getRequestHeaders(),
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ch_name: characters[this_chid].name,
|
ch_name: startedCharacter?.name,
|
||||||
file_name: characters[this_chid].chat,
|
file_name: startedChatFile,
|
||||||
avatar_url: characters[this_chid].avatar,
|
avatar_url: startedCharacter?.avatar,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -8014,6 +8060,14 @@ export async function getChat() {
|
||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentCharacter = startedChid !== undefined ? characters[startedChid] : null;
|
||||||
|
const stillActive = startedSelectedGroup === selected_group
|
||||||
|
&& startedChid === this_chid
|
||||||
|
&& currentCharacter?.chat === startedChatFile;
|
||||||
|
if (!stillActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
/** @type {ChatHeader} */
|
/** @type {ChatHeader} */
|
||||||
const chatHeader = data.shift();
|
const chatHeader = data.shift();
|
||||||
|
|
@ -8029,9 +8083,9 @@ export async function getChat() {
|
||||||
if (usePayloadTransport && windowedCursor) {
|
if (usePayloadTransport && windowedCursor) {
|
||||||
setWindowedChatState({
|
setWindowedChatState({
|
||||||
kind: 'character',
|
kind: 'character',
|
||||||
characterName: characters[this_chid].name,
|
characterName: currentCharacter.name,
|
||||||
avatarUrl: characters[this_chid].avatar,
|
avatarUrl: currentCharacter.avatar,
|
||||||
fileName: characters[this_chid].chat,
|
fileName: currentCharacter.chat,
|
||||||
cursor: windowedCursor,
|
cursor: windowedCursor,
|
||||||
hasMoreBefore: windowedHasMoreBefore,
|
hasMoreBefore: windowedHasMoreBefore,
|
||||||
savedMessageCount: chat.length,
|
savedMessageCount: chat.length,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ import {
|
||||||
clearWindowedChatState,
|
clearWindowedChatState,
|
||||||
DEFAULT_CHAT_WINDOW_LINES,
|
DEFAULT_CHAT_WINDOW_LINES,
|
||||||
getWindowedChatState,
|
getWindowedChatState,
|
||||||
|
getWindowedChatKey,
|
||||||
|
mergeWindowedChatCursorOffset,
|
||||||
setWindowedChatState,
|
setWindowedChatState,
|
||||||
} from './tauri/chat/windowed-state.js';
|
} from './tauri/chat/windowed-state.js';
|
||||||
|
|
||||||
|
|
@ -206,7 +208,7 @@ async function regenerateGroup() {
|
||||||
* @param {string} chatId Chat ID
|
* @param {string} chatId Chat ID
|
||||||
* @returns {Promise<ChatFile>} Array of chat messages
|
* @returns {Promise<ChatFile>} Array of chat messages
|
||||||
*/
|
*/
|
||||||
async function loadGroupChat(chatId) {
|
async function loadGroupChat(chatId, { updateWindowState = false } = {}) {
|
||||||
const normalizedChatId = String(chatId || '').trim();
|
const normalizedChatId = String(chatId || '').trim();
|
||||||
if (!normalizedChatId) {
|
if (!normalizedChatId) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -219,18 +221,20 @@ async function loadGroupChat(chatId) {
|
||||||
allowNotFound: true,
|
allowNotFound: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.cursor) {
|
if (updateWindowState && String(getCurrentChatId() || '').trim() === normalizedChatId) {
|
||||||
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
|
if (window.cursor) {
|
||||||
setWindowedChatState({
|
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
|
||||||
kind: 'group',
|
setWindowedChatState({
|
||||||
id: normalizedChatId,
|
kind: 'group',
|
||||||
cursor: window.cursor,
|
id: normalizedChatId,
|
||||||
hasMoreBefore: window.hasMoreBefore,
|
cursor: window.cursor,
|
||||||
savedMessageCount: messageCount,
|
hasMoreBefore: window.hasMoreBefore,
|
||||||
dirtyFromIndex: messageCount,
|
savedMessageCount: messageCount,
|
||||||
});
|
dirtyFromIndex: messageCount,
|
||||||
} else {
|
});
|
||||||
clearWindowedChatState();
|
} else {
|
||||||
|
clearWindowedChatState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.payload;
|
return window.payload;
|
||||||
|
|
@ -303,12 +307,25 @@ export async function getGroupChat(groupId, reload = false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startedGroupId = groupId;
|
||||||
|
const startedChatId = group.chat_id;
|
||||||
|
const isStillActive = () => selected_group === startedGroupId && getCurrentChatId() === startedChatId;
|
||||||
|
|
||||||
// Run validation before any loading
|
// Run validation before any loading
|
||||||
await validateGroup(group);
|
await validateGroup(group);
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await unshallowGroupMembers(groupId);
|
await unshallowGroupMembers(groupId);
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const chat_id = group.chat_id;
|
const chat_id = group.chat_id;
|
||||||
const data = await loadGroupChat(chat_id);
|
const data = await loadGroupChat(chat_id, { updateWindowState: true });
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const metadata = data?.[0]?.chat_metadata ?? {};
|
const metadata = data?.[0]?.chat_metadata ?? {};
|
||||||
const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
|
const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
|
||||||
|
|
||||||
|
|
@ -323,17 +340,26 @@ export async function getGroupChat(groupId, reload = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadItemizedPrompts(getCurrentChatId());
|
await loadItemizedPrompts(getCurrentChatId());
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (group && Array.isArray(group.members) && freshChat) {
|
if (group && Array.isArray(group.members) && freshChat) {
|
||||||
chat.splice(0, chat.length);
|
chat.splice(0, chat.length);
|
||||||
chatElement.find('.mes').remove();
|
chatElement.find('.mes').remove();
|
||||||
for (let member of group.members) {
|
for (let member of group.members) {
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const character = characters.find(x => x.avatar === member || x.name === member);
|
const character = characters.find(x => x.avatar === member || x.name === member);
|
||||||
if (!character) {
|
if (!character) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mes = await getFirstCharacterMessage(character);
|
const mes = await getFirstCharacterMessage(character);
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// No first message
|
// No first message
|
||||||
if (!(mes?.mes)) {
|
if (!(mes?.mes)) {
|
||||||
|
|
@ -347,10 +373,16 @@ export async function getGroupChat(groupId, reload = false) {
|
||||||
}
|
}
|
||||||
await saveGroupChat(groupId, false);
|
await saveGroupChat(groupId, false);
|
||||||
} else if (Array.isArray(data) && data.length) {
|
} else if (Array.isArray(data) && data.length) {
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
chat.splice(0, chat.length, ...data);
|
chat.splice(0, chat.length, ...data);
|
||||||
chat.forEach(ensureMessageMediaIsArray);
|
chat.forEach(ensureMessageMediaIsArray);
|
||||||
chatElement.find('.mes').remove();
|
chatElement.find('.mes').remove();
|
||||||
await printMessages();
|
await printMessages();
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const windowState = getWindowedChatState();
|
const windowState = getWindowedChatState();
|
||||||
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chat_id) {
|
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chat_id) {
|
||||||
|
|
@ -363,6 +395,9 @@ export async function getGroupChat(groupId, reload = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChatMetadata(metadata, true);
|
updateChatMetadata(metadata, true);
|
||||||
|
if (!isStillActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (reload) {
|
if (reload) {
|
||||||
select_group_chats(groupId, true);
|
select_group_chats(groupId, true);
|
||||||
|
|
@ -695,35 +730,46 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
|
||||||
String(error?.code || '').toLowerCase() === 'integrity'
|
String(error?.code || '').toLowerCase() === 'integrity'
|
||||||
|| /integrity/i.test(String(error?.message || ''));
|
|| /integrity/i.test(String(error?.message || ''));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isTauriChatPayloadTransportEnabled()) {
|
if (isTauriChatPayloadTransportEnabled()) {
|
||||||
const windowState = getWindowedChatState();
|
const windowState = getWindowedChatState();
|
||||||
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
|
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
|
||||||
const {
|
const expectedWindowKey = getWindowedChatKey(windowState);
|
||||||
patch,
|
const expectedCursorOffset = windowState.cursor.offset;
|
||||||
savedMessageCount: nextSavedMessageCount,
|
const {
|
||||||
dirtyFromIndex: nextDirtyFromIndex,
|
patch,
|
||||||
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
|
savedMessageCount: nextSavedMessageCount,
|
||||||
|
dirtyFromIndex: nextDirtyFromIndex,
|
||||||
|
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
|
||||||
|
|
||||||
const cursor = await patchGroupChatPayloadWindowed({
|
const cursor = await patchGroupChatPayloadWindowed({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
cursor: windowState.cursor,
|
cursor: windowState.cursor,
|
||||||
header: JSON.stringify(chatHeader),
|
header: JSON.stringify(chatHeader),
|
||||||
patch,
|
patch,
|
||||||
force: Boolean(force),
|
force: Boolean(force),
|
||||||
});
|
});
|
||||||
|
|
||||||
setWindowedChatState({
|
const activeWindowState = getWindowedChatState();
|
||||||
...windowState,
|
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
|
||||||
cursor,
|
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
|
||||||
savedMessageCount: nextSavedMessageCount,
|
const nextWindowState = {
|
||||||
dirtyFromIndex: nextDirtyFromIndex,
|
...activeWindowState,
|
||||||
});
|
cursor: mergedCursor,
|
||||||
} else {
|
};
|
||||||
await saveGroupChatPayload({
|
|
||||||
id: chatId,
|
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
|
||||||
payload,
|
nextWindowState.savedMessageCount = nextSavedMessageCount;
|
||||||
force: Boolean(force),
|
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWindowedChatState(nextWindowState);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await saveGroupChatPayload({
|
||||||
|
id: chatId,
|
||||||
|
payload,
|
||||||
|
force: Boolean(force),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,33 @@ export function clearWindowedChatState() {
|
||||||
currentWindow = null;
|
currentWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWindowedChatKey(windowState) {
|
||||||
|
if (!windowState) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowState.kind === 'group') {
|
||||||
|
return `group:${String(windowState.id || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `character:${String(windowState.characterName || '').trim()}|${String(windowState.avatarUrl || '').trim()}|${String(windowState.fileName || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeWindowedChatCursorOffset(activeCursor, nextCursor) {
|
||||||
|
if (!nextCursor) {
|
||||||
|
return activeCursor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeCursor) {
|
||||||
|
return nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nextCursor,
|
||||||
|
offset: activeCursor.offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function requireWindowedCounter(value, label) {
|
function requireWindowedCounter(value, label) {
|
||||||
const normalized = Number(value);
|
const normalized = Number(value);
|
||||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||||
|
|
|
||||||
59
tests/windowed-payload-cursor-errors.test.mjs
Normal file
59
tests/windowed-payload-cursor-errors.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
async function importFresh(modulePath) {
|
||||||
|
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||||
|
return import(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('prompt-backfill: isWindowedCursorInvalidError matches signature mismatch', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||||
|
const { isWindowedCursorInvalidError } = mod;
|
||||||
|
|
||||||
|
assert.equal(isWindowedCursorInvalidError(new Error('Cursor signature mismatch')), true);
|
||||||
|
assert.equal(isWindowedCursorInvalidError('Bad Request: Cursor signature mismatch'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompt-backfill: isWindowedCursorInvalidError matches out-of-bounds and line boundary', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||||
|
const { isWindowedCursorInvalidError } = mod;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isWindowedCursorInvalidError('Internal error: Cursor offset is out of bounds for /tmp/chat.jsonl'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isWindowedCursorInvalidError('Validation error: Cursor offset is not a line boundary'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompt-backfill: isWindowedCursorInvalidError matches before-chat-payload-body error', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||||
|
const { isWindowedCursorInvalidError } = mod;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isWindowedCursorInvalidError('Cursor offset is before chat payload body for /tmp/chat.jsonl'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompt-backfill: isWindowedCursorInvalidError ignores non-cursor errors', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||||
|
const { isWindowedCursorInvalidError } = mod;
|
||||||
|
|
||||||
|
assert.equal(isWindowedCursorInvalidError(new Error('boom')), false);
|
||||||
|
assert.equal(isWindowedCursorInvalidError('permission denied: no'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompt-backfill: isWindowedCursorInvalidError requires known cursor failure hints', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||||
|
const { isWindowedCursorInvalidError } = mod;
|
||||||
|
|
||||||
|
assert.equal(isWindowedCursorInvalidError('cursor is present but message is unrelated'), false);
|
||||||
|
assert.equal(isWindowedCursorInvalidError({ message: 'cursor: ???' }), false);
|
||||||
|
});
|
||||||
54
tests/windowed-payload-guards-contract.test.mjs
Normal file
54
tests/windowed-payload-guards-contract.test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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)), '..');
|
||||||
|
|
||||||
|
test('windowed payload: showMoreMessages implements single-flight + CAS commit', async () => {
|
||||||
|
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /\bwindowedShowMoreMessagesPending\b/);
|
||||||
|
assert.match(source, /existingPending\?\.\s*state\s*===\s*windowState/);
|
||||||
|
assert.match(source, /getWindowedChatState\(\)\s*!==\s*windowState/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed payload: clearChat(clearData:true) invalidates windowed state immediately', async () => {
|
||||||
|
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||||
|
|
||||||
|
const clearChatStart = source.indexOf('export async function clearChat');
|
||||||
|
assert.ok(clearChatStart >= 0);
|
||||||
|
const slice = source.slice(clearChatStart, clearChatStart + 600);
|
||||||
|
|
||||||
|
assert.match(slice, /cancelDebouncedMetadataSave\(\);/);
|
||||||
|
assert.match(slice, /if\s*\(\s*clearData\s*\)\s*\{\s*clearWindowedChatState\(\);\s*\}/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed payload: getChat drops stale tail-load results before committing UI/state', async () => {
|
||||||
|
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||||
|
|
||||||
|
const getChatStart = source.indexOf('export async function getChat()');
|
||||||
|
assert.ok(getChatStart >= 0);
|
||||||
|
const slice = source.slice(getChatStart, getChatStart + 3200);
|
||||||
|
|
||||||
|
assert.match(slice, /const\s+startedChid\s*=\s*this_chid\s*;/);
|
||||||
|
assert.match(slice, /\bconst\s+stillActive\s*=\s*[\s\S]*?;\s*if\s*\(!stillActive\)\s*\{\s*return;\s*\}/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed payload: group tail-load does not mutate window state for background reads', async () => {
|
||||||
|
const source = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /async function loadGroupChat\(\s*chatId\s*,\s*\{\s*updateWindowState\s*=\s*false\s*\}\s*=\s*\{\s*\}\s*\)/);
|
||||||
|
assert.match(source, /if\s*\(\s*updateWindowState[\s\S]*?setWindowedChatState\s*\(/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed payload: windowed patch commit is guarded and preserves active cursor offset', async () => {
|
||||||
|
const script = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||||
|
const groupChats = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(script, /\bgetWindowedChatKey\b/);
|
||||||
|
assert.match(script, /\bmergeWindowedChatCursorOffset\b/);
|
||||||
|
assert.match(groupChats, /\bgetWindowedChatKey\b/);
|
||||||
|
assert.match(groupChats, /\bmergeWindowedChatCursorOffset\b/);
|
||||||
|
});
|
||||||
111
tests/windowed-payload-jsonl.test.mjs
Normal file
111
tests/windowed-payload-jsonl.test.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
async function importFresh(modulePath) {
|
||||||
|
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||||
|
return import(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('jsonl: payloadToJsonl formats newline-delimited JSON', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { payloadToJsonl } = mod;
|
||||||
|
|
||||||
|
const payload = [{ a: 1 }, { b: 2 }];
|
||||||
|
assert.equal(payloadToJsonl(payload), `${JSON.stringify(payload[0])}\n${JSON.stringify(payload[1])}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: payloadToJsonl rejects non-object entries', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { payloadToJsonl } = mod;
|
||||||
|
|
||||||
|
assert.throws(() => payloadToJsonl([null]), /must be an object/i);
|
||||||
|
assert.throws(() => payloadToJsonl([1]), /must be an object/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: jsonlToPayload handles BOM on first payload line', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { jsonlToPayload } = mod;
|
||||||
|
|
||||||
|
const bom = '\uFEFF';
|
||||||
|
const payload = jsonlToPayload(`${bom}{"a":1}\n{"b":2}`);
|
||||||
|
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: jsonlToPayload ignores whitespace-only lines', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { jsonlToPayload } = mod;
|
||||||
|
|
||||||
|
const payload = jsonlToPayload('\n \n{"a":1}\n\t\n');
|
||||||
|
assert.deepEqual(payload, [{ a: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: jsonlToPayload surfaces line numbers on invalid JSON', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { jsonlToPayload } = mod;
|
||||||
|
|
||||||
|
assert.throws(() => jsonlToPayload('{"a":1}\n{bad}\n'), /Invalid JSONL at line 2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: payloadToJsonlByteChunks round-trips and respects maxChunkBytes', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { payloadToJsonl, payloadToJsonlByteChunks } = mod;
|
||||||
|
|
||||||
|
const payload = [{ a: 1 }, { b: 2 }, { c: 3 }];
|
||||||
|
const maxChunkBytes = 16;
|
||||||
|
const chunks = Array.from(payloadToJsonlByteChunks(payload, { maxChunkBytes }));
|
||||||
|
|
||||||
|
assert.ok(chunks.length > 1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
assert.ok(chunk.byteLength <= maxChunkBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||||
|
assert.equal(combined.toString('utf8'), payloadToJsonl(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: jsonlStreamToPayload parses chunked stream input', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { jsonlStreamToPayload } = mod;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('{"a":1}\n{"b":'));
|
||||||
|
controller.enqueue(encoder.encode('2}\n{"c":3}\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await jsonlStreamToPayload(stream);
|
||||||
|
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }, { c: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jsonl: jsonlStreamToPayload cancels reader on parse error during streaming', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||||
|
const { jsonlStreamToPayload } = mod;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let pushed = false;
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
pull(controller) {
|
||||||
|
if (pushed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushed = true;
|
||||||
|
controller.enqueue(encoder.encode('{"a":1}\n{bad}\n{"c":3}\n'));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
canceled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(() => jsonlStreamToPayload(stream), /Invalid JSONL at line 2/);
|
||||||
|
assert.equal(canceled, true);
|
||||||
|
});
|
||||||
166
tests/windowed-payload-state.test.mjs
Normal file
166
tests/windowed-payload-state.test.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
async function importFresh(modulePath) {
|
||||||
|
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||||
|
return import(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessages(count) {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({ id: index, mes: `m-${index}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('windowed-state: get/set/clear windowed chat state', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { clearWindowedChatState, getWindowedChatState, setWindowedChatState } = mod;
|
||||||
|
|
||||||
|
clearWindowedChatState();
|
||||||
|
assert.equal(getWindowedChatState(), null);
|
||||||
|
|
||||||
|
const state = { kind: 'character', fileName: 'a', savedMessageCount: 0, dirtyFromIndex: 0 };
|
||||||
|
setWindowedChatState(state);
|
||||||
|
assert.equal(getWindowedChatState(), state);
|
||||||
|
|
||||||
|
clearWindowedChatState();
|
||||||
|
assert.equal(getWindowedChatState(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: getWindowedChatKey trims and separates kinds', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { getWindowedChatKey } = mod;
|
||||||
|
|
||||||
|
assert.equal(getWindowedChatKey(null), '');
|
||||||
|
assert.equal(getWindowedChatKey({ kind: 'group', id: ' grp-1 ' }), 'group:grp-1');
|
||||||
|
|
||||||
|
assert.equal(getWindowedChatKey({
|
||||||
|
kind: 'character',
|
||||||
|
characterName: ' Alice ',
|
||||||
|
avatarUrl: ' /User%20Avatars/a.png ',
|
||||||
|
fileName: ' chat-1 ',
|
||||||
|
}), 'character:Alice|/User%20Avatars/a.png|chat-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: mergeWindowedChatCursorOffset preserves active offset', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { mergeWindowedChatCursorOffset } = mod;
|
||||||
|
|
||||||
|
assert.equal(mergeWindowedChatCursorOffset(null, null), null);
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeWindowedChatCursorOffset(null, { offset: 10, size: 1, modifiedMillis: 2 }),
|
||||||
|
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeWindowedChatCursorOffset({ offset: 10, size: 1, modifiedMillis: 2 }, null),
|
||||||
|
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeWindowedChatCursorOffset(
|
||||||
|
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||||
|
{ offset: 999, size: 2, modifiedMillis: 3 },
|
||||||
|
),
|
||||||
|
{ offset: 10, size: 2, modifiedMillis: 3 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: buildWindowedPayloadPatch rewriteFromIndex when dirtyFromIndex < savedMessageCount', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { buildWindowedPayloadPatch } = mod;
|
||||||
|
|
||||||
|
const messages = buildMessages(5);
|
||||||
|
const windowState = { savedMessageCount: 5, dirtyFromIndex: 2 };
|
||||||
|
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||||
|
|
||||||
|
assert.equal(result.patch.kind, 'rewriteFromIndex');
|
||||||
|
assert.equal(result.patch.startIndex, 2);
|
||||||
|
assert.deepEqual(result.patch.lines, messages.slice(2).map((entry) => JSON.stringify(entry)));
|
||||||
|
assert.equal(result.savedMessageCount, messages.length);
|
||||||
|
assert.equal(result.dirtyFromIndex, messages.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: buildWindowedPayloadPatch truncates when messages shorter than savedMessageCount', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { buildWindowedPayloadPatch } = mod;
|
||||||
|
|
||||||
|
const messages = buildMessages(3);
|
||||||
|
const windowState = { savedMessageCount: 5, dirtyFromIndex: 5 };
|
||||||
|
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||||
|
|
||||||
|
assert.deepEqual(result.patch, { kind: 'rewriteFromIndex', startIndex: 3, lines: [] });
|
||||||
|
assert.equal(result.savedMessageCount, messages.length);
|
||||||
|
assert.equal(result.dirtyFromIndex, messages.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: buildWindowedPayloadPatch appends when messages longer than savedMessageCount', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { buildWindowedPayloadPatch } = mod;
|
||||||
|
|
||||||
|
const messages = buildMessages(5);
|
||||||
|
const windowState = { savedMessageCount: 3, dirtyFromIndex: 3 };
|
||||||
|
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||||
|
|
||||||
|
assert.equal(result.patch.kind, 'append');
|
||||||
|
assert.deepEqual(result.patch.lines, messages.slice(3).map((entry) => JSON.stringify(entry)));
|
||||||
|
assert.equal(result.savedMessageCount, messages.length);
|
||||||
|
assert.equal(result.dirtyFromIndex, messages.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: buildWindowedPayloadPatch full rewrite when unchanged but non-empty', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { buildWindowedPayloadPatch } = mod;
|
||||||
|
|
||||||
|
const messages = buildMessages(2);
|
||||||
|
const windowState = { savedMessageCount: 2, dirtyFromIndex: 2 };
|
||||||
|
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||||
|
|
||||||
|
assert.deepEqual(result.patch, {
|
||||||
|
kind: 'rewriteFromIndex',
|
||||||
|
startIndex: 0,
|
||||||
|
lines: messages.map((entry) => JSON.stringify(entry)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: buildWindowedPayloadPatch returns empty append for empty message list', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { buildWindowedPayloadPatch } = mod;
|
||||||
|
|
||||||
|
const result = buildWindowedPayloadPatch([], { savedMessageCount: 0, dirtyFromIndex: 0 }, 'chat');
|
||||||
|
assert.deepEqual(result.patch, { kind: 'append', lines: [] });
|
||||||
|
assert.equal(result.savedMessageCount, 0);
|
||||||
|
assert.equal(result.dirtyFromIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: shiftWindowedMessageSaveState shifts counters without mutating original', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { shiftWindowedMessageSaveState } = mod;
|
||||||
|
|
||||||
|
const windowState = { kind: 'character', savedMessageCount: 2, dirtyFromIndex: 1, foo: 'bar' };
|
||||||
|
const shifted = shiftWindowedMessageSaveState(windowState, 3, 'chat');
|
||||||
|
|
||||||
|
assert.notEqual(shifted, windowState);
|
||||||
|
assert.equal(shifted.foo, 'bar');
|
||||||
|
assert.equal(shifted.savedMessageCount, 5);
|
||||||
|
assert.equal(shifted.dirtyFromIndex, 4);
|
||||||
|
|
||||||
|
assert.equal(windowState.savedMessageCount, 2);
|
||||||
|
assert.equal(windowState.dirtyFromIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowed-state: readWindowedMessageSaveState throws on missing counters', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||||
|
const { readWindowedMessageSaveState } = mod;
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => readWindowedMessageSaveState({}, 'chat'),
|
||||||
|
/savedMessageCount is missing/i,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => readWindowedMessageSaveState({ savedMessageCount: 0 }, 'chat'),
|
||||||
|
/dirtyFromIndex is missing/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
39
tests/windowed-payload-transport-normalize.test.mjs
Normal file
39
tests/windowed-payload-transport-normalize.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
async function importFresh(modulePath) {
|
||||||
|
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||||
|
return import(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('transport: normalizeChatFileName strips .jsonl and trims', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||||
|
const { normalizeChatFileName } = mod;
|
||||||
|
|
||||||
|
assert.equal(normalizeChatFileName(' hello.jsonl '), 'hello');
|
||||||
|
assert.equal(normalizeChatFileName('world.JSONL'), 'world');
|
||||||
|
assert.equal(normalizeChatFileName('already-normalized'), 'already-normalized');
|
||||||
|
assert.equal(normalizeChatFileName(''), '');
|
||||||
|
assert.equal(normalizeChatFileName(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transport: resolveCharacterDirectoryId prefers avatar internal id', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||||
|
const { resolveCharacterDirectoryId } = mod;
|
||||||
|
|
||||||
|
assert.equal(resolveCharacterDirectoryId('Alice', 'User Avatars/abc123.png'), 'abc123');
|
||||||
|
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=foo.png'), 'foo');
|
||||||
|
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=my%20avatar.png'), 'my avatar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transport: resolveCharacterDirectoryId falls back to character name when avatar is missing', async () => {
|
||||||
|
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||||
|
const { resolveCharacterDirectoryId } = mod;
|
||||||
|
|
||||||
|
assert.equal(resolveCharacterDirectoryId(' Alice ', null), 'Alice');
|
||||||
|
assert.equal(resolveCharacterDirectoryId(' Alice ', ''), 'Alice');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue