diff --git a/index.js b/index.js index 61b542b..2a61193 100644 --- a/index.js +++ b/index.js @@ -17841,6 +17841,7 @@ async function onReroll({ fromFloor } = {}) { onManualExtract, refreshPanelLiveState, rollbackGraphForReroll, + setLastExtractionStatus, setRuntimeStatus, toastr, }, diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 3443de3..c96b88d 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -115,6 +115,22 @@ function cloneSerializable(value, fallback = null) { } } +function setExtractionProgressStatus( + runtime, + text, + meta = "", + level = "info", + options = {}, +) { + if (typeof runtime?.setLastExtractionStatus === "function") { + runtime.setLastExtractionStatus(text, meta, level, options); + return; + } + if (options?.syncRuntime !== false && typeof runtime?.setRuntimeStatus === "function") { + runtime.setRuntimeStatus(text, meta, level); + } +} + function resolveLatestAssistantDialogueFloor(chat = []) { const map = buildDialogueFloorMap(chat); const assistantDialogueFloors = Array.isArray(map.assistantDialogueFloors) @@ -866,6 +882,7 @@ export async function onManualExtractController(runtime, options = {}) { const taskLabel = String(options?.taskLabel || "手动提取").trim() || "手动提取"; const toastTitle = String(options?.toastTitle || `ST-BME ${taskLabel}`).trim() || `ST-BME ${taskLabel}`; + const showStartToast = options?.showStartToast !== false; const lockedEndFloor = toSafeFloor(options?.lockedEndFloor, null); if (!runtime.ensureGraphMutationReady(taskLabel)) return; const pendingPersistGate = await maybeRetryPendingPersistence( @@ -907,6 +924,10 @@ export async function onManualExtractController(runtime, options = {}) { const assistantTurns = runtime.getAssistantTurns(chat); const lastProcessed = runtime.getLastProcessedAssistantFloor(); const pendingAssistantTurns = assistantTurns.filter((i) => i > lastProcessed); + const targetAssistantTurns = pendingAssistantTurns.filter((i) => { + if (lockedEndFloor != null && i > lockedEndFloor) return false; + return true; + }); if (pendingAssistantTurns.length === 0) { runtime.toastr.info("没有待提取的新回复"); return; @@ -920,18 +941,24 @@ export async function onManualExtractController(runtime, options = {}) { newEdges: 0, batches: 0, }; + let processedAssistantTurns = 0; const warnings = []; runtime.setIsExtracting(true); const extractionController = runtime.beginStageAbortController("extraction"); const extractionSignal = extractionController.signal; - runtime.setLastExtractionStatus( + setExtractionProgressStatus( + runtime, `${taskLabel}中`, lockedEndFloor != null - ? `待处理 assistant 楼层 ${pendingAssistantTurns.length} 条 · 截止 chatIndex ${lockedEndFloor}` - : `待处理 assistant 楼层 ${pendingAssistantTurns.length} 条`, + ? `待处理 AI 回复 ${targetAssistantTurns.length} 条 · 截止 chatIndex ${lockedEndFloor}` + : `待处理 AI 回复 ${targetAssistantTurns.length} 条`, "running", - { syncRuntime: true, toastKind: "info", toastTitle }, + { + syncRuntime: true, + toastKind: showStartToast ? "info" : "", + toastTitle, + }, ); try { while (true) { @@ -967,11 +994,30 @@ export async function onManualExtractController(runtime, options = {}) { totals.updatedNodes += batchResult.result.updatedNodes || 0; totals.newEdges += batchResult.result.newEdges || 0; totals.batches++; + processedAssistantTurns += batchAssistantTurns.length; if (Array.isArray(batchResult.effects?.warnings)) { warnings.push(...batchResult.effects.warnings); } + const totalTurnsForDisplay = Math.max( + processedAssistantTurns, + targetAssistantTurns.length, + ); + setExtractionProgressStatus( + runtime, + `${taskLabel}中`, + totalTurnsForDisplay > 0 + ? `已处理 ${processedAssistantTurns}/${totalTurnsForDisplay} 条 AI 回复 · 当前楼层 ${startIdx}-${endIdx} · 累计 ${totals.batches} 批` + : `当前楼层 ${startIdx}-${endIdx} · 累计 ${totals.batches} 批`, + "running", + { + syncRuntime: true, + toastKind: "", + toastTitle, + }, + ); + if (batchResult.historyAdvanceAllowed === false) { warnings.push( batchResult.batchStatus?.persistence?.reason || @@ -986,7 +1032,8 @@ export async function onManualExtractController(runtime, options = {}) { } if (totals.batches === 0) { - runtime.setLastExtractionStatus( + setExtractionProgressStatus( + runtime, "无待提取内容", lockedEndFloor != null ? "指定范围内没有新的 assistant 回复需要处理" @@ -1121,12 +1168,18 @@ export async function onExtractionTaskController(runtime, options = {}) { : rerunTask.endFloor, ]; - runtime.setRuntimeStatus( - "重新提取中", + setExtractionProgressStatus( + runtime, + "重新提取准备中", fallbackInfo.fallbackToLatest ? `范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor} 命中旧批次,但当前将退化为从 ${effectiveDialogueRange[0]} 到最新重提` : `准备重提范围 ${rerunTask.startFloor} ~ ${rerunTask.endFloor}`, fallbackInfo.fallbackToLatest ? "warning" : "running", + { + syncRuntime: true, + toastKind: "info", + toastTitle: "ST-BME 重新提取", + }, ); const rollbackResult = await runtime.rollbackGraphForReroll( @@ -1149,11 +1202,28 @@ export async function onExtractionTaskController(runtime, options = {}) { }); } + const rollbackDesc = + rollbackResult.effectiveFromFloor !== fallbackInfo.startAssistantChatIndex + ? `已按批次边界回滚到楼层 ${rollbackResult.effectiveFromFloor},正在开始重新提取` + : `已回滚到楼层 ${fallbackInfo.startAssistantChatIndex},正在开始重新提取`; + setExtractionProgressStatus( + runtime, + "重新提取中", + rollbackDesc, + "running", + { + syncRuntime: true, + toastKind: "", + toastTitle: "ST-BME 重新提取", + }, + ); + await runManualExtract({ drainAll: true, lockedEndFloor: effectiveLockedEndFloor, taskLabel: "重新提取", toastTitle: "ST-BME 重新提取", + showStartToast: false, }); return { @@ -1254,12 +1324,18 @@ export async function onRerollController(runtime, { fromFloor } = {}) { targetFloor = assistantTurns[assistantTurns.length - 1]; } - runtime.setRuntimeStatus( - "重新提取中", + setExtractionProgressStatus( + runtime, + "重新提取准备中", Number.isFinite(targetFloor) ? `准备从楼层 ${targetFloor} 开始回滚并重新提取` : "准备回滚最新 AI 楼并重新提取", "running", + { + syncRuntime: true, + toastKind: "info", + toastTitle: "ST-BME 重 Roll", + }, ); const lastProcessed = runtime.getLastProcessedAssistantFloor(); @@ -1289,10 +1365,14 @@ export async function onRerollController(runtime, { fromFloor } = {}) { rollbackResult = await runtime.rollbackGraphForReroll(targetFloor, context); } catch (e) { if (runtime.isAbortError(e)) { - runtime.setRuntimeStatus( + setExtractionProgressStatus( + runtime, "重新提取已取消", e.message || "聊天已切换", "warning", + { + syncRuntime: true, + }, ); return { success: false, @@ -1309,10 +1389,14 @@ export async function onRerollController(runtime, { fromFloor } = {}) { } if (!rollbackResult?.success) { - runtime.setRuntimeStatus( + setExtractionProgressStatus( + runtime, "重新提取失败", rollbackResult.error || "回滚失败", "error", + { + syncRuntime: true, + }, ); runtime.toastr?.error?.(rollbackResult.error, "ST-BME 重 Roll"); return rollbackResult; @@ -1326,7 +1410,19 @@ export async function onRerollController(runtime, { fromFloor } = {}) { timeOut: 2500, }); - await runtime.onManualExtract({ drainAll: false }); + setExtractionProgressStatus( + runtime, + "重新提取中", + rerollDesc, + "running", + { + syncRuntime: true, + toastKind: "", + toastTitle: "ST-BME 重 Roll", + }, + ); + + await runtime.onManualExtract({ drainAll: false, showStartToast: false }); runtime.refreshPanelLiveState(); return { ...rollbackResult, diff --git a/tests/dialogue-floor-range-tasks.mjs b/tests/dialogue-floor-range-tasks.mjs index 05f5f0e..a399d9d 100644 --- a/tests/dialogue-floor-range-tasks.mjs +++ b/tests/dialogue-floor-range-tasks.mjs @@ -4,7 +4,10 @@ import { buildDialogueFloorMap, normalizeDialogueFloorRange, } from "../maintenance/chat-history.js"; -import { onExtractionTaskController } from "../maintenance/extraction-controller.js"; +import { + onExtractionTaskController, + onManualExtractController, +} from "../maintenance/extraction-controller.js"; import { onRebuildSummaryStateController } from "../ui/ui-actions-controller.js"; const chat = [ @@ -50,6 +53,7 @@ const chat = [ manual: [], warning: [], info: [], + extractionStatus: [], }; const runtime = { getContext() { @@ -62,6 +66,9 @@ const chat = [ return true; }, setRuntimeStatus() {}, + setLastExtractionStatus(text, meta, level) { + calls.extractionStatus.push({ text, meta, level }); + }, rollbackGraphForReroll: async (fromFloor) => { calls.rollback.push(fromFloor); return { success: true, effectiveFromFloor: fromFloor }; @@ -91,6 +98,11 @@ const chat = [ assert.equal(calls.manual.length, 1); assert.equal(calls.manual[0].lockedEndFloor, null); assert.equal(calls.manual[0].taskLabel, "重新提取"); + assert.equal(calls.manual[0].showStartToast, false); + assert.equal(calls.extractionStatus[0]?.text, "重新提取准备中"); + assert.match(calls.extractionStatus[0]?.meta || "", /退化为从 2 到最新重提/); + assert.equal(calls.extractionStatus[1]?.text, "重新提取中"); + assert.match(calls.extractionStatus[1]?.meta || "", /正在开始重新提取/); assert.match(result.reason, /退化为从起始楼层到最新重提/); } @@ -98,6 +110,7 @@ const chat = [ const calls = { rollback: [], manual: [], + extractionStatus: [], }; const runtime = { getContext() { @@ -110,6 +123,9 @@ const chat = [ return true; }, setRuntimeStatus() {}, + setLastExtractionStatus(text, meta, level) { + calls.extractionStatus.push({ text, meta, level }); + }, rollbackGraphForReroll: async (fromFloor) => { calls.rollback.push(fromFloor); return { success: true, effectiveFromFloor: fromFloor }; @@ -131,6 +147,95 @@ const chat = [ assert.equal(result.fallbackToLatest, false); assert.deepEqual(calls.rollback, [6]); assert.equal(calls.manual[0].lockedEndFloor, 6); + assert.equal(calls.manual[0].showStartToast, false); + assert.equal(calls.extractionStatus[0]?.text, "重新提取准备中"); +} + +{ + const statuses = []; + let lastProcessedAssistantFloor = -1; + const runtime = { + getIsExtracting() { + return false; + }, + ensureGraphMutationReady() { + return true; + }, + async recoverHistoryIfNeeded() { + return true; + }, + getCurrentGraph() { + return { historyState: {} }; + }, + getContext() { + return { chat }; + }, + getAssistantTurns() { + return [2, 6]; + }, + getLastProcessedAssistantFloor() { + return lastProcessedAssistantFloor; + }, + getSettings() { + return { extractEvery: 1 }; + }, + clampInt(value, fallback, min, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); + }, + setIsExtracting() {}, + beginStageAbortController() { + return { signal: null }; + }, + finishStageAbortController() {}, + setLastExtractionStatus(text, meta, level) { + statuses.push({ text, meta, level }); + }, + async executeExtractionBatch({ endIdx }) { + lastProcessedAssistantFloor = endIdx; + return { + success: true, + result: { + newNodes: 1, + updatedNodes: 0, + newEdges: 1, + }, + effects: { + warnings: [], + }, + historyAdvanceAllowed: true, + }; + }, + isAbortError() { + return false; + }, + refreshPanelLiveState() {}, + retryPendingGraphPersist: async () => ({ accepted: true }), + toastr: { + info() {}, + success() {}, + warning() {}, + error() {}, + }, + }; + + await onManualExtractController(runtime, { + taskLabel: "重新提取", + toastTitle: "ST-BME 重新提取", + showStartToast: false, + }); + + assert.equal(statuses[0]?.text, "重新提取中"); + assert.match(statuses[0]?.meta || "", /待处理 AI 回复 2 条/); + assert.ok( + statuses.some( + (entry) => + entry.text === "重新提取中" && + /已处理 1\/2 条 AI 回复/.test(entry.meta || ""), + ), + ); + assert.equal(statuses[statuses.length - 1]?.text, "重新提取完成"); } { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 3315c4f..a355035 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -731,6 +731,9 @@ function createRerollHarness() { setRuntimeStatus(text, meta = "", level = "info") { context.runtimeStatus = { text, meta, level }; }, + setLastExtractionStatus(text, meta = "", level = "info") { + context.lastExtractionStatus = { text, meta, level }; + }, clearInjectionState() { context.clearInjectionCalls += 1; },