Merge branch 'Youzini-afk:main' into main

This commit is contained in:
Hao19911125
2026-04-03 13:51:46 +08:00
committed by GitHub
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;
}
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);

View File

@@ -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

View File

@@ -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();
}

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

View File

@@ -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();