diff --git a/bme-sync.js b/bme-sync.js index a3c8f03..d6f5585 100644 --- a/bme-sync.js +++ b/bme-sync.js @@ -49,6 +49,23 @@ function normalizeRemoteFilenameCandidate(fileName, fallbackValue = "ST-BME_sync return sanitized || fallbackValue; } +function normalizeLegacyRemoteFilenameCandidate( + fileName, + fallbackValue = "ST-BME_sync_unknown.json", +) { + const raw = String(fileName ?? ""); + const normalized = + typeof raw.normalize === "function" ? raw.normalize("NFKD") : raw; + const sanitized = normalized + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^A-Za-z0-9._~-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^\.+/g, "") + .slice(0, BME_SYNC_FILENAME_MAX_LENGTH) + .trim(); + return sanitized || fallbackValue; +} + function buildSyncFilename(chatId) { const normalizedChatId = normalizeChatId(chatId); const legacyName = `${BME_SYNC_FILE_PREFIX}${normalizedChatId}${BME_SYNC_FILE_SUFFIX}`; @@ -74,6 +91,18 @@ function buildSyncFilename(chatId) { return `${core}${BME_SYNC_FILE_SUFFIX}`; } +function buildLegacyRawSyncFilename(chatId) { + return `${BME_SYNC_FILE_PREFIX}${normalizeChatId(chatId)}${BME_SYNC_FILE_SUFFIX}`; +} + +function rememberResolvedSyncFilename(chatId, filename) { + const normalizedChatId = normalizeChatId(chatId); + const normalizedFilename = String(filename || "").trim(); + if (!normalizedChatId || !normalizedFilename) return ""; + sanitizedFilenameByChatId.set(normalizedChatId, normalizedFilename); + return normalizedFilename; +} + function normalizeRevision(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) return 0; @@ -933,28 +962,56 @@ async function sanitizeFilename(fileName, options = {}) { } try { - const fetchImpl = getFetch(options); - const response = await fetchImpl("/api/files/sanitize-filename", { - method: "POST", - headers: { - ...getRequestHeadersSafe(options), - "Content-Type": "application/json", - }, - body: JSON.stringify({ fileName }), - }); - - if (!response.ok) { - return finalFallback; - } - - const payload = await response.json().catch(() => null); - const sanitized = String(payload?.fileName || "").trim(); + const sanitized = await requestSanitizedFilename(fileName, options); return normalizeRemoteFilenameCandidate(sanitized, finalFallback); } catch { return finalFallback; } } +async function requestSanitizedFilename(fileName, options = {}) { + if (options.disableRemoteSanitize) { + return String(fileName || ""); + } + + const fetchImpl = getFetch(options); + const response = await fetchImpl("/api/files/sanitize-filename", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileName }), + }); + + if (!response.ok) { + return ""; + } + + const payload = await response.json().catch(() => null); + return String(payload?.fileName || "").trim(); +} + +async function resolveLegacySyncFilename(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const rawFileName = buildLegacyRawSyncFilename(normalizedChatId); + const legacyFallback = normalizeLegacyRemoteFilenameCandidate( + rawFileName, + "ST-BME_sync_unknown.json", + ); + + if (options.disableRemoteSanitize) { + return legacyFallback; + } + + try { + const sanitized = await requestSanitizedFilename(rawFileName, options); + return normalizeLegacyRemoteFilenameCandidate(sanitized, legacyFallback); + } catch { + return legacyFallback; + } +} + async function resolveSyncFilename(chatId, options = {}) { const normalizedChatId = normalizeChatId(chatId); if (!normalizedChatId) { @@ -968,10 +1025,41 @@ async function resolveSyncFilename(chatId, options = {}) { const rawFileName = buildSyncFilename(normalizedChatId); const sanitized = await sanitizeFilename(rawFileName, options); const finalName = normalizeRemoteFilenameCandidate(sanitized, rawFileName); - sanitizedFilenameByChatId.set(normalizedChatId, finalName); + rememberResolvedSyncFilename(normalizedChatId, finalName); return finalName; } +async function resolveSyncFilenameCandidates(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + throw new Error("chatId 不能为空"); + } + + const candidates = []; + const pushCandidate = (value) => { + const normalizedValue = String(value || "").trim(); + if (!normalizedValue || candidates.includes(normalizedValue)) return; + candidates.push(normalizedValue); + }; + + if (sanitizedFilenameByChatId.has(normalizedChatId)) { + pushCandidate(sanitizedFilenameByChatId.get(normalizedChatId)); + } + + const primaryRawFileName = buildSyncFilename(normalizedChatId); + const primarySanitized = await sanitizeFilename(primaryRawFileName, options); + pushCandidate( + normalizeRemoteFilenameCandidate(primarySanitized, primaryRawFileName), + ); + + const legacyRawFileName = buildLegacyRawSyncFilename(normalizedChatId); + if (legacyRawFileName !== primaryRawFileName) { + pushCandidate(await resolveLegacySyncFilename(normalizedChatId, options)); + } + + return candidates; +} + async function readRemoteSnapshot(chatId, options = {}) { const normalizedChatId = normalizeChatId(chatId); if (!normalizedChatId) { @@ -983,70 +1071,81 @@ async function readRemoteSnapshot(chatId, options = {}) { }; } - const filename = await resolveSyncFilename(normalizedChatId, options); const fetchImpl = getFetch(options); - const cacheBust = `t=${Date.now()}`; - const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`; + const candidateFilenames = await resolveSyncFilenameCandidates( + normalizedChatId, + options, + ); + let lastNotFoundFilename = candidateFilenames[0] || ""; - let response; - try { - response = await fetchImpl(url, { - method: "GET", - cache: "no-store", - }); - } catch (error) { - console.warn("[ST-BME] 读取远端同步文件失败:", error); - return { - exists: false, - status: "network-error", - filename, - snapshot: null, - error, - }; + for (const filename of candidateFilenames) { + const cacheBust = `t=${Date.now()}`; + const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`; + + let response; + try { + response = await fetchImpl(url, { + method: "GET", + cache: "no-store", + }); + } catch (error) { + console.warn("[ST-BME] 读取远端同步文件失败:", error); + return { + exists: false, + status: "network-error", + filename, + snapshot: null, + error, + }; + } + + if (response.status === 404) { + lastNotFoundFilename = filename; + continue; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + const error = new Error(errorText || `HTTP ${response.status}`); + console.warn("[ST-BME] 读取远端同步文件失败:", error); + return { + exists: false, + status: "http-error", + filename, + snapshot: null, + error, + statusCode: response.status, + }; + } + + try { + const remotePayload = await response.json(); + const snapshot = normalizeSyncSnapshot(remotePayload, normalizedChatId); + rememberResolvedSyncFilename(normalizedChatId, filename); + return { + exists: true, + status: "ok", + filename, + snapshot, + }; + } catch (error) { + console.warn("[ST-BME] 解析远端同步文件失败:", error); + return { + exists: false, + status: "invalid-json", + filename, + snapshot: null, + error, + }; + } } - if (response.status === 404) { - return { - exists: false, - status: "not-found", - filename, - snapshot: null, - }; - } - - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - const error = new Error(errorText || `HTTP ${response.status}`); - console.warn("[ST-BME] 读取远端同步文件失败:", error); - return { - exists: false, - status: "http-error", - filename, - snapshot: null, - error, - statusCode: response.status, - }; - } - - try { - const remotePayload = await response.json(); - const snapshot = normalizeSyncSnapshot(remotePayload, normalizedChatId); - return { - exists: true, - status: "ok", - filename, - snapshot, - }; - } catch (error) { - console.warn("[ST-BME] 解析远端同步文件失败:", error); - return { - exists: false, - status: "invalid-json", - filename, - snapshot: null, - error, - }; - } + return { + exists: false, + status: "not-found", + filename: lastNotFoundFilename, + snapshot: null, + }; } async function writeSnapshotToRemote(snapshot, chatId, options = {}) { @@ -1671,37 +1770,48 @@ export async function deleteRemoteSyncFile(chatId, options = {}) { } try { - const filename = await resolveSyncFilename(normalizedChatId, options); const fetchImpl = getFetch(options); - const response = await fetchImpl("/api/files/delete", { - method: "POST", - headers: { - ...getRequestHeadersSafe(options), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - path: `/user/files/${filename}`, - }), - }); + const filenames = await resolveSyncFilenameCandidates( + normalizedChatId, + options, + ); + let lastNotFoundFilename = filenames[0] || ""; - if (response.status === 404) { + for (const filename of filenames) { + const response = await fetchImpl("/api/files/delete", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + path: `/user/files/${filename}`, + }), + }); + + if (response.status === 404) { + lastNotFoundFilename = filename; + continue; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + sanitizedFilenameByChatId.delete(normalizedChatId); return { - deleted: false, + deleted: true, chatId: normalizedChatId, filename, - reason: "not-found", }; } - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - throw new Error(errorText || `HTTP ${response.status}`); - } - return { - deleted: true, + deleted: false, chatId: normalizedChatId, - filename, + filename: lastNotFoundFilename, + reason: "not-found", }; } catch (error) { console.warn("[ST-BME] 删除远端同步文件失败:", error); diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 7171923..bd282ab 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -1203,7 +1203,7 @@ async function buildPlannerMessages(rawUserInput) { // Extra user blocks before user message for (const b of enaUserBlocks) { const content = await renderTemplateAll(b.content, env, messageVars); - messages.splice(Math.max(0, messages.length - 1), 0, { role: 'system', content: `【extra-user-block】\n${content}` }); + messages.splice(Math.max(0, messages.length - 1), 0, { role: 'user', content: `【extra-user-block】\n${content}` }); } // 7) Assistant blocks diff --git a/hide-engine.js b/hide-engine.js index 0b16ab8..7eef184 100644 --- a/hide-engine.js +++ b/hide-engine.js @@ -170,9 +170,69 @@ function clearManagedState() { hideState.lastProcessedLength = 0; } -function restoreManagedSystemFlags(chat, runtime = {}) { - if (!Array.isArray(chat) || hideState.managedSystemIndices.size === 0) { +function isManagedSystemMessage(message) { + return Boolean( + message?.is_system === true && + message?.extra && + typeof message.extra === "object" && + message.extra[BME_HIDE_HASH_MARKER] === true, + ); +} + +function collectManagedSystemIndices(chat) { + if (!Array.isArray(chat) || chat.length === 0) return []; + const indices = []; + for (let index = 0; index < chat.length; index++) { + if (isManagedSystemMessage(chat[index])) { + indices.push(index); + } + } + return indices; +} + +function hydrateManagedStateFromChat( + chat, + chatKey = getCurrentChatKey(), + { bootstrapLength = false } = {}, +) { + if (!Array.isArray(chat)) { hideState.managedSystemIndices.clear(); + hideState.hiddenRangeEnd = -1; + return { managedCount: 0, hiddenRangeEnd: -1 }; + } + + const managedIndices = collectManagedSystemIndices(chat); + hideState.managedSystemIndices.clear(); + for (const index of managedIndices) { + hideState.managedSystemIndices.add(index); + } + + hideState.managedChatRef = chat; + hideState.managedChatKey = chatKey; + hideState.hiddenRangeEnd = + managedIndices.length > 0 ? managedIndices[managedIndices.length - 1] : -1; + if (managedIndices.length > 0 && bootstrapLength) { + hideState.lastProcessedLength = chat.length; + } + + return { + managedCount: managedIndices.length, + hiddenRangeEnd: hideState.hiddenRangeEnd, + }; +} + +function restoreManagedSystemFlags(chat, runtime = {}) { + if (!Array.isArray(chat)) { + hideState.managedSystemIndices.clear(); + return 0; + } + + if (hideState.managedSystemIndices.size === 0) { + hydrateManagedStateFromChat(chat, getCurrentChatKey(runtime), { + bootstrapLength: false, + }); + } + if (hideState.managedSystemIndices.size === 0) { return 0; } @@ -279,11 +339,22 @@ async function runHideApply(settings = {}, runtime = {}, options = {}) { const chatKey = getCurrentChatKey(runtime); const previousChatKey = hideState.managedChatKey; + const hadTrackedState = + hideState.managedSystemIndices.size > 0 || + hideState.hiddenRangeEnd >= 0 || + (Number.isFinite(hideState.lastProcessedLength) && + hideState.lastProcessedLength > 0); + adoptManagedChat(chat, chatKey, runtime); + hydrateManagedStateFromChat(chat, chatKey, { + bootstrapLength: !hadTrackedState, + }); const sameChat = previousChatKey !== null && chatKey !== null && previousChatKey === chatKey; - const previousHiddenEnd = sameChat ? hideState.hiddenRangeEnd : -1; - const previousLength = sameChat ? hideState.lastProcessedLength : 0; - adoptManagedChat(chat, chatKey, runtime); + const previousHiddenEnd = hideState.hiddenRangeEnd; + const previousLength = + sameChat && Number.isFinite(hideState.lastProcessedLength) + ? hideState.lastProcessedLength + : 0; hideState.lastProcessedLength = chatLength; if (!normalized.enabled || normalized.hideLastN <= 0) { @@ -435,6 +506,9 @@ export async function unhideAll(runtime = {}) { return buildResult({ chatLength }); } + hydrateManagedStateFromChat(chatInfo.chat, getCurrentChatKey(runtime), { + bootstrapLength: false, + }); const { shownCount } = await unhideCurrentRange(runtime, version, { full: true, }); @@ -454,6 +528,12 @@ export async function unhideAll(runtime = {}) { export function resetHideState(runtime = {}) { clearScheduledTimer(runtime); beginOperation(); + const chatInfo = getCurrentChatInfo(runtime); + if (Array.isArray(chatInfo.chat)) { + hydrateManagedStateFromChat(chatInfo.chat, chatInfo.chatId || null, { + bootstrapLength: false, + }); + } restoreManagedSystemFlags(hideState.managedChatRef, runtime); clearManagedState(); } diff --git a/tests/hide-engine.mjs b/tests/hide-engine.mjs index 52514d9..33745f9 100644 --- a/tests/hide-engine.mjs +++ b/tests/hide-engine.mjs @@ -144,9 +144,40 @@ async function testResetClearsStateWithoutIssuingCommands() { }); } +async function testUnhideAllRecoversPersistedManagedMarkersAfterStateLoss() { + resetHideState(); + const chat = [ + { + mes: "user-1", + is_user: true, + is_system: true, + extra: { __st_bme_hide_managed: true }, + }, + { + mes: "assistant-1", + is_user: false, + is_system: true, + extra: { __st_bme_hide_managed: true }, + }, + { mes: "user-2", is_user: true, is_system: false }, + ]; + const runtime = createRuntime(chat); + + const result = await unhideAll(runtime); + + assert.equal(result.active, false); + assert.equal(result.shownCount, 3); + assert.deepEqual(runtime.commands, ["/unhide 0-2"]); + assert.equal(chat[0].is_system, false); + assert.equal(chat[1].is_system, false); + assert.equal(chat[0].extra, undefined); + assert.equal(chat[1].extra, undefined); +} + await testApplyUsesNativeHide(); await testDisableUnhidesManagedRange(); await testIncrementalOnlyHidesOverflowDelta(); await testResetClearsStateWithoutIssuingCommands(); +await testUnhideAllRecoversPersistedManagedMarkersAfterStateLoss(); console.log("hide-engine tests passed"); diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs index 97aa965..1073aaf 100644 --- a/tests/indexeddb-sync.mjs +++ b/tests/indexeddb-sync.mjs @@ -341,6 +341,49 @@ async function testDownloadImport() { assert.equal(db.lastImportPayload.nodes[0].id, "remote-node"); } +async function testLegacyRemoteFilenameFallbackAndReuse() { + const { fetch, remoteFiles, logs } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const chatId = "chat~legacy name"; + const db = new FakeDb(chatId); + dbByChatId.set(chatId, db); + + remoteFiles.set("ST-BME_sync_chat~legacy_name.json", { + meta: { + schemaVersion: 1, + chatId, + revision: 4, + deviceId: "remote-device", + lastModified: 400, + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "legacy-node", updatedAt: 300 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 3, + extractionCount: 2, + }, + }); + + const runtime = buildRuntimeOptions({ dbByChatId, fetch }); + const status = await getRemoteStatus(chatId, runtime); + assert.equal(status.exists, true); + assert.equal(status.filename, "ST-BME_sync_chat~legacy_name.json"); + + const downloadResult = await download(chatId, runtime); + assert.equal(downloadResult.downloaded, true); + assert.equal(downloadResult.filename, "ST-BME_sync_chat~legacy_name.json"); + assert.equal(db.lastImportPayload.nodes[0].id, "legacy-node"); + + const uploadResult = await upload(chatId, runtime); + assert.equal(uploadResult.uploaded, true); + assert.equal(uploadResult.filename, "ST-BME_sync_chat~legacy_name.json"); + assert.equal(logs.uploadedPayloads.at(-1)?.name, "ST-BME_sync_chat~legacy_name.json"); +} + async function testMergeRules() { const local = { meta: { @@ -595,6 +638,38 @@ async function testDeleteRemoteSyncFile() { assert.equal(logs.deleteCalls, 2); } +async function testDeleteRemoteSyncFileFallsBackToLegacyFilename() { + const { fetch, remoteFiles, logs } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const chatId = "chat~legacy delete"; + dbByChatId.set(chatId, new FakeDb(chatId)); + remoteFiles.set("ST-BME_sync_chat~legacy_delete.json", { + meta: { + schemaVersion: 1, + chatId, + revision: 1, + lastModified: 10, + deviceId: "remote-device", + nodeCount: 0, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: -1, + extractionCount: 0, + }, + }); + + const runtime = buildRuntimeOptions({ dbByChatId, fetch }); + const deleteResult = await deleteRemoteSyncFile(chatId, runtime); + assert.equal(deleteResult.deleted, true); + assert.equal(deleteResult.filename, "ST-BME_sync_chat~legacy_delete.json"); + assert.equal(logs.deleteCalls, 2, "应先尝试新文件名,再回退删除 legacy 文件名"); +} + async function testAutoSyncOnVisibility() { const { fetch, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); @@ -748,10 +823,12 @@ async function main() { await testUploadPayloadMetaFirstAndDebounce(); await testUploadSanitizesIllegalChatIdFilename(); await testDownloadImport(); + await testLegacyRemoteFilenameFallbackAndReuse(); await testMergeRules(); await testMergeRuntimeMetaPolicies(); await testSyncNowLockAndAutoSync(); await testDeleteRemoteSyncFile(); + await testDeleteRemoteSyncFileFallsBackToLegacyFilename(); await testAutoSyncOnVisibility(); await testSyncNowRemoteReadErrorPath(); await testSyncAppliedHook();