Fix mobile action status cleanup

This commit is contained in:
Youzini-afk
2026-03-28 21:03:51 +08:00
parent 67e6e29bb2
commit 4e26849c6c
4 changed files with 327 additions and 4 deletions

View File

@@ -2084,7 +2084,7 @@ async function syncVectorState({
currentGraph.vectorIndexState.lastWarning = validation.error; currentGraph.vectorIndexState.lastWarning = validation.error;
currentGraph.vectorIndexState.dirty = true; currentGraph.vectorIndexState.dirty = true;
setLastVectorStatus("向量不可用", validation.error, "warning", { setLastVectorStatus("向量不可用", validation.error, "warning", {
syncRuntime: false, syncRuntime: true,
}); });
return { return {
insertedHashes: [], insertedHashes: [],
@@ -2105,13 +2105,13 @@ async function syncVectorState({
"向量完成", "向量完成",
`${scopeLabel} · indexed ${result.stats?.indexed ?? 0} · pending ${result.stats?.pending ?? 0}`, `${scopeLabel} · indexed ${result.stats?.indexed ?? 0} · pending ${result.stats?.pending ?? 0}`,
"success", "success",
{ syncRuntime: false }, { syncRuntime: true },
); );
return result; return result;
} catch (error) { } catch (error) {
if (isAbortError(error)) { if (isAbortError(error)) {
setLastVectorStatus("向量已终止", scopeLabel, "warning", { setLastVectorStatus("向量已终止", scopeLabel, "warning", {
syncRuntime: false, syncRuntime: true,
}); });
return { return {
insertedHashes: [], insertedHashes: [],
@@ -5235,6 +5235,11 @@ async function onRebuild() {
); );
const previousUiState = snapshotRuntimeUiState(); const previousUiState = snapshotRuntimeUiState();
const settings = getSettings(); const settings = getSettings();
setRuntimeStatus(
"图谱重建中",
`当前聊天 ${Array.isArray(chat) ? chat.length : 0} 条消息`,
"running",
);
currentGraph = normalizeGraphRuntimeState( currentGraph = normalizeGraphRuntimeState(
createEmptyGraph(), createEmptyGraph(),
@@ -5259,12 +5264,30 @@ async function onRebuild() {
}), }),
); );
saveGraphToChat({ reason: "manual-rebuild-complete" }); saveGraphToChat({ reason: "manual-rebuild-complete" });
setLastExtractionStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批提取`,
"success",
{
syncRuntime: false,
},
);
if (currentGraph.vectorIndexState?.lastWarning) { if (currentGraph.vectorIndexState?.lastWarning) {
setRuntimeStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批,但向量仍待修复`,
"warning",
);
toastr.warning( toastr.warning(
`图谱已重建,但向量索引仍待修复: ${currentGraph.vectorIndexState.lastWarning}`, `图谱已重建,但向量索引仍待修复: ${currentGraph.vectorIndexState.lastWarning}`,
); );
} else { } else {
setRuntimeStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批,图谱与向量索引已刷新`,
"success",
);
toastr.success("图谱与向量索引已按当前聊天全量重建"); toastr.success("图谱与向量索引已按当前聊天全量重建");
} }
} catch (error) { } catch (error) {
@@ -5274,9 +5297,19 @@ async function onRebuild() {
); );
restoreRuntimeUiState(previousUiState); restoreRuntimeUiState(previousUiState);
saveGraphToChat({ reason: "manual-rebuild-restore-previous" }); saveGraphToChat({ reason: "manual-rebuild-restore-previous" });
setLastExtractionStatus(
"图谱重建失败",
error?.message || String(error),
"error",
{
syncRuntime: true,
},
);
throw new Error( throw new Error(
`图谱重建失败,已恢复到重建前状态: ${error?.message || error}`, `图谱重建失败,已恢复到重建前状态: ${error?.message || error}`,
); );
} finally {
refreshPanelLiveState();
} }
} }
@@ -5569,6 +5602,14 @@ async function onManualExtract() {
} }
if (totals.batches === 0) { if (totals.batches === 0) {
setLastExtractionStatus(
"无待提取内容",
"没有新的 assistant 回复需要处理",
"info",
{
syncRuntime: true,
},
);
toastr.info("没有待提取的新回复"); toastr.info("没有待提取的新回复");
return; return;
} }
@@ -5613,6 +5654,7 @@ async function onManualExtract() {
} finally { } finally {
finishStageAbortController("extraction", extractionController); finishStageAbortController("extraction", extractionController);
isExtracting = false; isExtracting = false;
refreshPanelLiveState();
} }
} }
@@ -5702,6 +5744,14 @@ async function onReroll({ fromFloor } = {}) {
targetFloor = assistantTurns[assistantTurns.length - 1]; targetFloor = assistantTurns[assistantTurns.length - 1];
} }
setRuntimeStatus(
"重新提取中",
Number.isFinite(targetFloor)
? `准备从楼层 ${targetFloor} 开始回滚并重新提取`
: "准备回滚最新 AI 楼并重新提取",
"running",
);
const lastProcessed = getLastProcessedAssistantFloor(); const lastProcessed = getLastProcessedAssistantFloor();
const alreadyExtracted = targetFloor <= lastProcessed; const alreadyExtracted = targetFloor <= lastProcessed;
@@ -5727,6 +5777,11 @@ async function onReroll({ fromFloor } = {}) {
console.log(`[ST-BME] 重 Roll 开始,目标楼层: ${targetFloor}`); console.log(`[ST-BME] 重 Roll 开始,目标楼层: ${targetFloor}`);
const rollbackResult = await rollbackGraphForReroll(targetFloor, context); const rollbackResult = await rollbackGraphForReroll(targetFloor, context);
if (!rollbackResult.success) { if (!rollbackResult.success) {
setRuntimeStatus(
"重新提取失败",
rollbackResult.error || "回滚失败",
"error",
);
toastr.error(rollbackResult.error, "ST-BME 重 Roll"); toastr.error(rollbackResult.error, "ST-BME 重 Roll");
return rollbackResult; return rollbackResult;
} }
@@ -5740,6 +5795,7 @@ async function onReroll({ fromFloor } = {}) {
}); });
await onManualExtract(); await onManualExtract();
refreshPanelLiveState();
return { return {
...rollbackResult, ...rollbackResult,
extractionTriggered: true, extractionTriggered: true,
@@ -5843,6 +5899,7 @@ async function onRebuildVectorIndex(range = null) {
); );
} finally { } finally {
finishStageAbortController("vector", vectorController); finishStageAbortController("vector", vectorController);
refreshPanelLiveState();
} }
} }

View File

@@ -1316,6 +1316,7 @@ function _bindActions() {
} }
} finally { } finally {
btn.style.opacity = ""; btn.style.opacity = "";
_refreshRuntimeStatus();
_refreshGraphAvailabilityState(); _refreshGraphAvailabilityState();
} }
}); });
@@ -1355,6 +1356,7 @@ function _bindActions() {
if (btn) { if (btn) {
btn.style.opacity = ""; btn.style.opacity = "";
} }
_refreshRuntimeStatus();
_refreshGraphAvailabilityState(); _refreshGraphAvailabilityState();
} }
}); });
@@ -1401,6 +1403,7 @@ function _bindActions() {
if (btn) { if (btn) {
btn.style.opacity = ""; btn.style.opacity = "";
} }
_refreshRuntimeStatus();
_refreshGraphAvailabilityState(); _refreshGraphAvailabilityState();
} }
}); });

View File

@@ -0,0 +1,260 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(__dirname, "../index.js");
const indexSource = await fs.readFile(indexPath, "utf8");
function extractSnippet(startMarker, endMarker) {
const start = indexSource.indexOf(startMarker);
const end = indexSource.indexOf(endMarker);
if (start < 0 || end < 0 || end <= start) {
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
}
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
}
const statusSnippet = extractSnippet(
"function setRuntimeStatus(",
"function notifyExtractionIssue(",
);
const vectorSnippet = extractSnippet(
"async function syncVectorState({",
"async function ensureVectorReadyIfNeeded(",
);
const manualExtractSnippet = extractSnippet(
"async function onManualExtract() {",
"async function onReroll(",
);
const rebuildSnippet = extractSnippet(
"async function onRebuild() {",
"async function onManualCompress() {",
);
function createBaseStatusContext() {
return {
console,
Date,
createUiStatus(text = "待命", meta = "", level = "idle") {
return {
text: String(text || "待命"),
meta: String(meta || ""),
level,
updatedAt: Date.now(),
};
},
runtimeStatus: { text: "待命", meta: "", level: "idle" },
lastExtractionStatus: { text: "待命", meta: "", level: "idle" },
lastVectorStatus: { text: "待命", meta: "", level: "idle" },
lastRecallStatus: { text: "待命", meta: "", level: "idle" },
lastStatusToastAt: {},
STATUS_TOAST_THROTTLE_MS: 1500,
_panelModule: {
updateFloatingBallStatus() {},
},
refreshPanelLiveState() {},
updateStageNotice() {},
notifyStatusToast() {},
toastr: {
info() {},
success() {},
warning() {},
error() {},
},
};
}
async function testVectorSyncTerminalStateUpdatesRuntime() {
const context = {
...createBaseStatusContext(),
currentGraph: {
vectorIndexState: {
dirty: true,
lastWarning: "",
},
},
ensureCurrentGraphRuntimeState() {
return context.currentGraph;
},
getEmbeddingConfig() {
return { mode: "direct" };
},
validateVectorConfig() {
return { valid: true };
},
async syncGraphVectorIndex() {
return {
insertedHashes: [],
stats: {
indexed: 12,
pending: 0,
},
};
},
getCurrentChatId() {
return "chat-mobile";
},
getVectorIndexStats() {
return { indexed: 12, pending: 0 };
},
isAbortError() {
return false;
},
markVectorStateDirty() {},
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${vectorSnippet}\nresult = { syncVectorState };`,
context,
{ filename: indexPath },
);
const result = await context.result.syncVectorState({ force: true });
assert.equal(result.stats.indexed, 12);
assert.equal(context.lastVectorStatus.text, "向量完成");
assert.equal(context.runtimeStatus.text, "向量完成");
assert.equal(context.runtimeStatus.level, "success");
}
async function testManualExtractNoBatchesDoesNotStayRunning() {
let assistantTurnCallCount = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
isExtracting: false,
currentGraph: {},
getCurrentChatId() {
return "chat-mobile";
},
ensureGraphMutationReady() {
return true;
},
async recoverHistoryIfNeeded() {
return true;
},
normalizeGraphRuntimeState(graph) {
return graph;
},
createEmptyGraph() {
return {};
},
getContext() {
return { chat };
},
getAssistantTurns() {
assistantTurnCallCount += 1;
return assistantTurnCallCount === 1 ? [1] : [];
},
getLastProcessedAssistantFloor() {
return 0;
},
clampInt(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
},
getSettings() {
return { extractEvery: 1 };
},
beginStageAbortController() {
return { signal: {} };
},
async executeExtractionBatch() {
throw new Error("不应进入批次执行");
},
isAbortError() {
return false;
},
finishStageAbortController() {},
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${manualExtractSnippet}\nresult = { onManualExtract };`,
context,
{ filename: indexPath },
);
await context.result.onManualExtract();
assert.equal(context.isExtracting, false);
assert.equal(context.lastExtractionStatus.text, "无待提取内容");
assert.equal(context.runtimeStatus.text, "无待提取内容");
assert.notEqual(context.runtimeStatus.level, "running");
}
async function testManualRebuildSetsTerminalRuntimeStatus() {
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
currentGraph: {
vectorIndexState: {
lastWarning: "",
},
batchJournal: [],
},
confirm() {
return true;
},
ensureGraphMutationReady() {
return true;
},
getContext() {
return { chat };
},
cloneGraphSnapshot(graph) {
return graph;
},
snapshotRuntimeUiState() {
return {};
},
getSettings() {
return {};
},
normalizeGraphRuntimeState(graph) {
return graph;
},
createEmptyGraph() {
return {
vectorIndexState: {
lastWarning: "",
},
batchJournal: [],
};
},
getCurrentChatId() {
return "chat-mobile";
},
clearInjectionState() {},
async prepareVectorStateForReplay() {},
async replayExtractionFromHistory() {
context.currentGraph.vectorIndexState.lastWarning = "";
return 2;
},
clearHistoryDirty() {},
buildRecoveryResult(status, extra = {}) {
return { status, ...extra };
},
saveGraphToChat() {},
restoreRuntimeUiState() {},
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${rebuildSnippet}\nresult = { onRebuild };`,
context,
{ filename: indexPath },
);
await context.result.onRebuild();
assert.equal(context.lastExtractionStatus.text, "图谱重建完成");
assert.equal(context.runtimeStatus.text, "图谱重建完成");
assert.equal(context.runtimeStatus.level, "success");
}
await testVectorSyncTerminalStateUpdatesRuntime();
await testManualExtractNoBatchesDoesNotStayRunning();
await testManualRebuildSetsTerminalRuntimeStatus();
console.log("mobile-status-regressions tests passed");

View File

@@ -372,6 +372,9 @@ function createRerollHarness() {
refreshPanelLiveState() { refreshPanelLiveState() {
context.refreshPanelCalls += 1; context.refreshPanelCalls += 1;
}, },
setRuntimeStatus(text, meta = "", level = "info") {
context.runtimeStatus = { text, meta, level };
},
clearInjectionState() { clearInjectionState() {
context.clearInjectionCalls += 1; context.clearInjectionCalls += 1;
}, },
@@ -1384,7 +1387,7 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
assert.equal(harness.prepareVectorStateCalls.length, 1); assert.equal(harness.prepareVectorStateCalls.length, 1);
assert.equal(harness.prepareVectorStateCalls[0][2].skipBackendPurge, true); assert.equal(harness.prepareVectorStateCalls[0][2].skipBackendPurge, true);
assert.equal(harness.saveGraphToChatCalls, 1); assert.equal(harness.saveGraphToChatCalls, 1);
assert.equal(harness.refreshPanelCalls, 1); assert.equal(harness.refreshPanelCalls, 2);
assert.equal(harness.clearInjectionCalls, 1); assert.equal(harness.clearInjectionCalls, 1);
assert.equal(harness.onManualExtractCalls, 1); assert.equal(harness.onManualExtractCalls, 1);
assert.equal(harness.currentGraph.historyState.processedMessageHashes[3], undefined); assert.equal(harness.currentGraph.historyState.processedMessageHashes[3], undefined);