Fix sync fallback, hide recovery, and planner roles

This commit is contained in:
Youzini-afk
2026-04-03 13:17:29 +08:00
parent 820dc8c77e
commit 48c8a7169c
5 changed files with 402 additions and 104 deletions

View File

@@ -49,6 +49,23 @@ function normalizeRemoteFilenameCandidate(fileName, fallbackValue = "ST-BME_sync
return sanitized || fallbackValue; 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) { function buildSyncFilename(chatId) {
const normalizedChatId = normalizeChatId(chatId); const normalizedChatId = normalizeChatId(chatId);
const legacyName = `${BME_SYNC_FILE_PREFIX}${normalizedChatId}${BME_SYNC_FILE_SUFFIX}`; const legacyName = `${BME_SYNC_FILE_PREFIX}${normalizedChatId}${BME_SYNC_FILE_SUFFIX}`;
@@ -74,6 +91,18 @@ function buildSyncFilename(chatId) {
return `${core}${BME_SYNC_FILE_SUFFIX}`; 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) { function normalizeRevision(value) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0; if (!Number.isFinite(parsed) || parsed < 0) return 0;
@@ -933,28 +962,56 @@ async function sanitizeFilename(fileName, options = {}) {
} }
try { try {
const fetchImpl = getFetch(options); const sanitized = await requestSanitizedFilename(fileName, 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();
return normalizeRemoteFilenameCandidate(sanitized, finalFallback); return normalizeRemoteFilenameCandidate(sanitized, finalFallback);
} catch { } catch {
return finalFallback; 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 = {}) { async function resolveSyncFilename(chatId, options = {}) {
const normalizedChatId = normalizeChatId(chatId); const normalizedChatId = normalizeChatId(chatId);
if (!normalizedChatId) { if (!normalizedChatId) {
@@ -968,10 +1025,41 @@ async function resolveSyncFilename(chatId, options = {}) {
const rawFileName = buildSyncFilename(normalizedChatId); const rawFileName = buildSyncFilename(normalizedChatId);
const sanitized = await sanitizeFilename(rawFileName, options); const sanitized = await sanitizeFilename(rawFileName, options);
const finalName = normalizeRemoteFilenameCandidate(sanitized, rawFileName); const finalName = normalizeRemoteFilenameCandidate(sanitized, rawFileName);
sanitizedFilenameByChatId.set(normalizedChatId, finalName); rememberResolvedSyncFilename(normalizedChatId, finalName);
return 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 = {}) { async function readRemoteSnapshot(chatId, options = {}) {
const normalizedChatId = normalizeChatId(chatId); const normalizedChatId = normalizeChatId(chatId);
if (!normalizedChatId) { if (!normalizedChatId) {
@@ -983,70 +1071,81 @@ async function readRemoteSnapshot(chatId, options = {}) {
}; };
} }
const filename = await resolveSyncFilename(normalizedChatId, options);
const fetchImpl = getFetch(options); const fetchImpl = getFetch(options);
const cacheBust = `t=${Date.now()}`; const candidateFilenames = await resolveSyncFilenameCandidates(
const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`; normalizedChatId,
options,
);
let lastNotFoundFilename = candidateFilenames[0] || "";
let response; for (const filename of candidateFilenames) {
try { const cacheBust = `t=${Date.now()}`;
response = await fetchImpl(url, { const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`;
method: "GET",
cache: "no-store", let response;
}); try {
} catch (error) { response = await fetchImpl(url, {
console.warn("[ST-BME] 读取远端同步文件失败:", error); method: "GET",
return { cache: "no-store",
exists: false, });
status: "network-error", } catch (error) {
filename, console.warn("[ST-BME] 读取远端同步文件失败:", error);
snapshot: null, return {
error, 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 {
return { exists: false,
exists: false, status: "not-found",
status: "not-found", filename: lastNotFoundFilename,
filename, snapshot: null,
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,
};
}
} }
async function writeSnapshotToRemote(snapshot, chatId, options = {}) { async function writeSnapshotToRemote(snapshot, chatId, options = {}) {
@@ -1671,37 +1770,48 @@ export async function deleteRemoteSyncFile(chatId, options = {}) {
} }
try { try {
const filename = await resolveSyncFilename(normalizedChatId, options);
const fetchImpl = getFetch(options); const fetchImpl = getFetch(options);
const response = await fetchImpl("/api/files/delete", { const filenames = await resolveSyncFilenameCandidates(
method: "POST", normalizedChatId,
headers: { options,
...getRequestHeadersSafe(options), );
"Content-Type": "application/json", let lastNotFoundFilename = filenames[0] || "";
},
body: JSON.stringify({
path: `/user/files/${filename}`,
}),
});
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 { return {
deleted: false, deleted: true,
chatId: normalizedChatId, chatId: normalizedChatId,
filename, filename,
reason: "not-found",
}; };
} }
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(errorText || `HTTP ${response.status}`);
}
return { return {
deleted: true, deleted: false,
chatId: normalizedChatId, chatId: normalizedChatId,
filename, filename: lastNotFoundFilename,
reason: "not-found",
}; };
} catch (error) { } catch (error) {
console.warn("[ST-BME] 删除远端同步文件失败:", error); console.warn("[ST-BME] 删除远端同步文件失败:", error);

View File

@@ -1203,7 +1203,7 @@ async function buildPlannerMessages(rawUserInput) {
// Extra user blocks before user message // Extra user blocks before user message
for (const b of enaUserBlocks) { for (const b of enaUserBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars); 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 // 7) Assistant blocks

View File

@@ -170,9 +170,69 @@ function clearManagedState() {
hideState.lastProcessedLength = 0; hideState.lastProcessedLength = 0;
} }
function restoreManagedSystemFlags(chat, runtime = {}) { function isManagedSystemMessage(message) {
if (!Array.isArray(chat) || hideState.managedSystemIndices.size === 0) { 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.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; return 0;
} }
@@ -279,11 +339,22 @@ async function runHideApply(settings = {}, runtime = {}, options = {}) {
const chatKey = getCurrentChatKey(runtime); const chatKey = getCurrentChatKey(runtime);
const previousChatKey = hideState.managedChatKey; 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 = const sameChat =
previousChatKey !== null && chatKey !== null && previousChatKey === chatKey; previousChatKey !== null && chatKey !== null && previousChatKey === chatKey;
const previousHiddenEnd = sameChat ? hideState.hiddenRangeEnd : -1; const previousHiddenEnd = hideState.hiddenRangeEnd;
const previousLength = sameChat ? hideState.lastProcessedLength : 0; const previousLength =
adoptManagedChat(chat, chatKey, runtime); sameChat && Number.isFinite(hideState.lastProcessedLength)
? hideState.lastProcessedLength
: 0;
hideState.lastProcessedLength = chatLength; hideState.lastProcessedLength = chatLength;
if (!normalized.enabled || normalized.hideLastN <= 0) { if (!normalized.enabled || normalized.hideLastN <= 0) {
@@ -435,6 +506,9 @@ export async function unhideAll(runtime = {}) {
return buildResult({ chatLength }); return buildResult({ chatLength });
} }
hydrateManagedStateFromChat(chatInfo.chat, getCurrentChatKey(runtime), {
bootstrapLength: false,
});
const { shownCount } = await unhideCurrentRange(runtime, version, { const { shownCount } = await unhideCurrentRange(runtime, version, {
full: true, full: true,
}); });
@@ -454,6 +528,12 @@ export async function unhideAll(runtime = {}) {
export function resetHideState(runtime = {}) { export function resetHideState(runtime = {}) {
clearScheduledTimer(runtime); clearScheduledTimer(runtime);
beginOperation(); beginOperation();
const chatInfo = getCurrentChatInfo(runtime);
if (Array.isArray(chatInfo.chat)) {
hydrateManagedStateFromChat(chatInfo.chat, chatInfo.chatId || null, {
bootstrapLength: false,
});
}
restoreManagedSystemFlags(hideState.managedChatRef, runtime); restoreManagedSystemFlags(hideState.managedChatRef, runtime);
clearManagedState(); clearManagedState();
} }

View File

@@ -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 testApplyUsesNativeHide();
await testDisableUnhidesManagedRange(); await testDisableUnhidesManagedRange();
await testIncrementalOnlyHidesOverflowDelta(); await testIncrementalOnlyHidesOverflowDelta();
await testResetClearsStateWithoutIssuingCommands(); await testResetClearsStateWithoutIssuingCommands();
await testUnhideAllRecoversPersistedManagedMarkersAfterStateLoss();
console.log("hide-engine tests passed"); console.log("hide-engine tests passed");

View File

@@ -341,6 +341,49 @@ async function testDownloadImport() {
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node"); 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() { async function testMergeRules() {
const local = { const local = {
meta: { meta: {
@@ -595,6 +638,38 @@ async function testDeleteRemoteSyncFile() {
assert.equal(logs.deleteCalls, 2); 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() { async function testAutoSyncOnVisibility() {
const { fetch, logs } = createMockFetchEnvironment(); const { fetch, logs } = createMockFetchEnvironment();
const dbByChatId = new Map(); const dbByChatId = new Map();
@@ -748,10 +823,12 @@ async function main() {
await testUploadPayloadMetaFirstAndDebounce(); await testUploadPayloadMetaFirstAndDebounce();
await testUploadSanitizesIllegalChatIdFilename(); await testUploadSanitizesIllegalChatIdFilename();
await testDownloadImport(); await testDownloadImport();
await testLegacyRemoteFilenameFallbackAndReuse();
await testMergeRules(); await testMergeRules();
await testMergeRuntimeMetaPolicies(); await testMergeRuntimeMetaPolicies();
await testSyncNowLockAndAutoSync(); await testSyncNowLockAndAutoSync();
await testDeleteRemoteSyncFile(); await testDeleteRemoteSyncFile();
await testDeleteRemoteSyncFileFallsBackToLegacyFilename();
await testAutoSyncOnVisibility(); await testAutoSyncOnVisibility();
await testSyncNowRemoteReadErrorPath(); await testSyncNowRemoteReadErrorPath();
await testSyncAppliedHook(); await testSyncAppliedHook();