feat(wiki): user-initiated cancel for in-progress raw material processing (#72)

This commit is contained in:
matevip 2026-05-08 15:03:10 +08:00
parent 2af2620b4c
commit 0357c9891d
11 changed files with 290 additions and 31 deletions

View File

@ -300,6 +300,23 @@ public class WikiController {
return R.ok();
}
@RequireWorkspaceRole("member")
@Operation(summary = "请求取消正在进行的处理(仅在 processing 状态有效)")
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/cancel")
public R<Void> cancelRaw(@PathVariable Long kbId, @PathVariable Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
}
// requestCancel is idempotent: a no-op when the row is not processing,
// so repeated clicks (or a click after the run already finished) are
// safe and do not surface an error to the user.
rawService.requestCancel(rawId);
return R.ok();
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "下载原始材料")
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")

View File

@ -46,9 +46,18 @@ public class WikiRawMaterialEntity {
/** 文件大小(字节) */
private Long fileSize;
/** 处理状态pending / processing / completed / failed */
/** 处理状态pending / processing / completed / failed / partial / cancelled */
private String processingStatus;
/**
* User-requested cancellation flag. Set to {@code true} via the cancel
* endpoint while a raw material is in {@code processing}. The pipeline
* observes the flag at its abort checkpoints and exits early with
* {@code processingStatus = "cancelled"}; the flag is cleared on the
* next successful claim for processing.
*/
private Boolean cancelRequested;
/** 上次处理时间 */
private LocalDateTime lastProcessedAt;

View File

@ -303,7 +303,17 @@ public class WikiProcessingService {
String finalStatus;
String finalDetail = null;
if (totalPages == 0) {
// Cancellation takes precedence over the normal terminal-state logic:
// chunks that observed the cancel flag returned early as "failed", but
// those aren't real failures the user asked to stop. Surface that
// intent explicitly so the UI can show "cancelled" instead of "failed"
// or "partial".
if (rawService.isCancelRequested(rawId)) {
finalDetail = "Cancelled by user (" + totalPages + " page(s) generated, "
+ (totalChunks - failedChunks) + "/" + totalChunks + " chunks completed before stop).";
rawService.updateProcessingStatus(rawId, "cancelled", finalDetail);
finalStatus = "cancelled";
} else if (totalPages == 0) {
// RFC-051 follow-up: previously this was an unconditional "failed".
// But chunks were already persisted (and the materials are searchable
// via wiki_semantic_search) the only thing that actually went wrong
@ -371,31 +381,34 @@ public class WikiProcessingService {
var terminalStage = switch (finalStatus) {
case "failed" -> vip.mate.wiki.job.WikiJobStage.FAILED;
case "partial" -> vip.mate.wiki.job.WikiJobStage.PARTIAL;
case "cancelled" -> vip.mate.wiki.job.WikiJobStage.CANCELLED;
default -> vip.mate.wiki.job.WikiJobStage.COMPLETED;
};
wikiJobService.transition(jobId, terminalStage);
} catch (Exception ignored) {}
}
// RFC-051 PR-2c: log every non-failed eager ingest. Failures already get a
// RAW_FAILED broadcast and an error message in the raw row. Title goes first
// so the log reads as "what just landed" instead of an opaque raw id.
if (logService != null && !"failed".equals(finalStatus)) {
// Skip the post-terminal side effects (log line, overview rebuild,
// KB-dirty event) for cancelled and failed runs. A cancelled run
// means the user explicitly stopped don't burn LLM tokens on
// overview regeneration over an unstable partial state.
boolean nonTerminalSideEffects = !"failed".equals(finalStatus) && !"cancelled".equals(finalStatus);
if (logService != null && nonTerminalSideEffects) {
String title = (raw.getTitle() == null || raw.getTitle().isBlank())
? ("raw#" + rawId) : raw.getTitle();
logService.append(kb.getId(), WikiLogService.EventType.INGEST,
"eager " + finalStatus + " · " + title
+ " · " + totalPages + " pages · " + totalChunks + " chunks");
}
// RFC-051 PR-2b: refresh overview stats whenever a raw lands in a terminal state
// (completed or partial). Failures don't shift the stats meaningfully.
if (overviewService != null && !"failed".equals(finalStatus)) {
// Refresh overview stats whenever a raw lands in a terminal state
// (completed or partial). Failures and cancellations don't shift the stats meaningfully.
if (overviewService != null && nonTerminalSideEffects) {
overviewService.rebuild(kb.getId());
}
// Tier 2: signal "KB content is dirty" so WikiNarrativeService can
// schedule (debounced) an LLM-generated overview narrative refresh.
// Stats rebuild above is sync; narrative regen runs after-commit.
if (!"failed".equals(finalStatus)) {
if (nonTerminalSideEffects) {
eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId()));
}
@ -410,7 +423,12 @@ public class WikiProcessingService {
// RFC-051 follow-up: trigger embedding whenever chunks landed, not only when
// pages were produced. Otherwise the partial-with-no-pages case above ends up
// with chunks in DB but never embedded, so semantic search silently misses them.
if (totalChunks > 0) {
// Skip the post-ingest embedding sweep when this run was cancelled.
// The user almost certainly stopped because the embedding provider
// is failing (out of credits, wrong key, etc.); kicking off another
// embedding pass on the same provider would just churn through
// every pending chunk and produce more "all chunks failed" noise.
if (totalChunks > 0 && !"cancelled".equals(finalStatus)) {
final Long fKbId = kb.getId();
WIKI_EXECUTOR.submit(() -> {
try {
@ -425,16 +443,39 @@ public class WikiProcessingService {
}
} catch (Exception e) {
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
kbService.updateStatus(kb.getId(), "active");
// Transition job to failed
if (wikiJobService != null && jobId != null) {
try { wikiJobService.transition(jobId, vip.mate.wiki.job.WikiJobStage.FAILED); } catch (Exception ignored) {}
// If the user requested cancellation while this run was in flight,
// surface the abort as 'cancelled' rather than 'failed' even when
// the exception bubbled up from somewhere mid-pipeline (e.g. a
// checkpoint rejected between chunks).
boolean cancelled = rawService.isCancelRequested(rawId);
String terminalStatus = cancelled ? "cancelled" : "failed";
String detail = cancelled
? "Cancelled by user (interrupted: " + (e.getMessage() == null ? "unknown" : e.getMessage()) + ")"
: e.getMessage();
if (cancelled) {
log.info("[Wiki] Processing cancelled for raw={}: {}", rawId, e.getMessage());
} else {
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
}
rawService.updateProcessingStatus(rawId, terminalStatus, detail);
kbService.updateStatus(kb.getId(), "active");
if (wikiJobService != null && jobId != null) {
try {
wikiJobService.transition(jobId, cancelled
? vip.mate.wiki.job.WikiJobStage.CANCELLED
: vip.mate.wiki.job.WikiJobStage.FAILED);
} catch (Exception ignored) {}
}
// Broadcast: cancelled rows reuse the COMPLETED event with status="cancelled"
// so subscribers can render the terminal-but-not-error UI; only true failures
// go through RAW_FAILED (which the UI surfaces as a red banner).
if (cancelled) {
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED,
java.util.Map.of("rawId", rawId, "status", "cancelled"));
} else {
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage()));
}
// RFC-012 M3广播异常终态
progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED,
java.util.Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage()));
} finally {
// RFC-012 M2 v2 UI v2写入最终进度并清理共享计数器
ProgressCounter pc = progressCounters.remove(rawId);
@ -2164,10 +2205,15 @@ public class WikiProcessingService {
* @return {@code true} if the raw is gone; caller should stop work
*/
private boolean isAborted(Long rawId, String ctx) {
if (rawService.getById(rawId) == null) {
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null) {
log.info("[Wiki] Aborting {} for raw={}: raw was deleted mid-processing", ctx, rawId);
return true;
}
if (Boolean.TRUE.equals(raw.getCancelRequested())) {
log.info("[Wiki] Aborting {} for raw={}: cancellation requested by user", ctx, rawId);
return true;
}
return false;
}

View File

@ -232,10 +232,47 @@ public class WikiRawMaterialService {
entity.setProgressPhase(null);
entity.setProgressTotal(0);
entity.setProgressDone(0);
// Fresh start clears any stale cancel request from a previous run.
entity.setCancelRequested(Boolean.FALSE);
rawMapper.updateById(entity);
return true;
}
/**
* Mark a raw material for cancellation. Only valid while it is currently
* being processed; for any other status this is a no-op so the call is
* idempotent and safe to retry from the UI.
*
* @return {@code true} if the flag was set, {@code false} otherwise
*/
@Transactional
public boolean requestCancel(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) {
return false;
}
if (!"processing".equals(entity.getProcessingStatus())) {
return false;
}
if (Boolean.TRUE.equals(entity.getCancelRequested())) {
// Already requested; treat as success without redundant write.
return true;
}
entity.setCancelRequested(Boolean.TRUE);
rawMapper.updateById(entity);
return true;
}
/**
* Returns {@code true} if the user has asked to cancel this raw material's
* current processing run. Used by abort checkpoints inside the processing
* pipeline to bail out early.
*/
public boolean isCancelRequested(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
return entity != null && Boolean.TRUE.equals(entity.getCancelRequested());
}
/**
* RFC-012 M2 v2 UI更新 wiki 两阶段消化的进度字段
* <p>
@ -266,6 +303,12 @@ public class WikiRawMaterialService {
if ("completed".equals(status)) {
entity.setLastProcessedAt(java.time.LocalDateTime.now());
}
// Cancellation flag is only meaningful while a row is being processed.
// Any transition out of 'processing' clears it so the field reflects
// an idle row's true state and the next reprocess starts clean.
if (!"processing".equals(status)) {
entity.setCancelRequested(Boolean.FALSE);
}
rawMapper.updateById(entity);
}

View File

@ -0,0 +1,6 @@
-- V95: cancellation flag for in-progress wiki raw material processing.
-- Lets the user request a stop on a long-running PDF analysis (e.g. when
-- the embedding model has run out of credits) without having to delete
-- the raw material. The processing pipeline checks the flag at its
-- existing abort checkpoints and bails out with a 'cancelled' status.
ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -0,0 +1,9 @@
-- V95: cancellation flag for in-progress wiki raw material processing.
-- Lets the user request a stop on a long-running PDF analysis (e.g. when
-- the embedding model has run out of credits) without having to delete
-- the raw material. The processing pipeline checks the flag at its
-- existing abort checkpoints and bails out with a 'cancelled' status.
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'cancel_requested');
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT FALSE', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -602,6 +602,8 @@ export const wikiApi = {
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
reprocessRaw: (kbId: number, rawId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
cancelRaw: (kbId: number, rawId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/cancel`),
downloadRaw: (kbId: number, rawId: number) =>
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
responseType: 'blob',

View File

@ -1683,6 +1683,9 @@ export default {
noRawMaterials: 'No raw materials yet',
reprocess: 'Reprocess',
resume: 'Resume',
cancel: 'Cancel processing',
cancelling: 'Cancelling…',
cancelledHint: 'Cancelled by user',
download: 'Download original file',
downloadFailed: 'Download failed',
processAll: 'Process All Pending',
@ -1711,6 +1714,8 @@ export default {
completed: 'COMPLETED',
partial: 'PARTIAL',
failed: 'FAILED',
cancelled: 'CANCELLED',
cancelling: 'CANCELLING…',
},
progress: {
preparing: 'Preparing…',

View File

@ -1695,6 +1695,9 @@ export default {
noRawMaterials: '暂无原始材料',
reprocess: '重新处理',
resume: '继续生成',
cancel: '取消处理',
cancelling: '正在取消…',
cancelledHint: '用户已取消处理',
download: '下载原始文件',
downloadFailed: '下载失败',
processAll: '处理所有待处理材料',
@ -1723,6 +1726,8 @@ export default {
completed: '已完成',
partial: '部分完成',
failed: '失败',
cancelled: '已取消',
cancelling: '正在取消…',
},
progress: {
preparing: '准备中…',

View File

@ -15,9 +15,15 @@
</div>
</div>
<div class="stage-labels">
<span v-for="stage in stages" :key="stage.key" class="stage-label" :class="{ active: stage.key === currentStage && !isTerminal, done: stage.key === currentStage && isTerminal }">
{{ t(`wiki.jobStage.${stage.key}`) }}
</span>
<div
v-for="(stage, idx) in stages"
:key="stage.key"
class="stage-label-cell"
>
<span class="stage-label" :class="{ active: stage.key === currentStage && !isTerminal, done: stage.key === currentStage && isTerminal }">
{{ t(`wiki.jobStage.${stage.key}`) }}
</span>
</div>
</div>
<!-- Model & progress info -->
@ -174,6 +180,16 @@ const elapsed = computed(() => {
flex: 1;
}
/* The last cell only contains a dot (no trailing connector line). With
* flex:1 it would reserve a full segment of empty space after the dot,
* which makes the final stage look stranded the connector going into
* it appears to stop short and there's a visible blank gap on the right.
* Pin the last cell to dot-width so the 6 connector lines distribute
* evenly between the 7 dots and the final dot anchors to the right edge. */
.stage-dot-group:last-child {
flex: 0 0 auto;
}
.stage-dot {
width: 10px;
height: 10px;
@ -201,15 +217,33 @@ const elapsed = computed(() => {
}
.stage-line.done { background: var(--mc-primary); }
/* Labels mirror the dot-row's flex structure so each label cell is the
* same width as its matching dot cell. The last cell pins to dot-width
* (matching .stage-dot-group:last-child above), and inside every cell the
* label is centered horizontally over the dot at the cell's left edge by
* shifting it half the dot width and translating back by half its own
* width keeping label text centered above each dot at any container
* width. */
.stage-labels {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.stage-label-cell {
flex: 1;
display: flex;
justify-content: flex-start;
overflow: visible;
}
.stage-label-cell:last-child {
flex: 0 0 auto;
}
.stage-label {
font-size: 9px;
color: var(--mc-text-tertiary);
text-align: center;
flex: 1;
white-space: nowrap;
margin-left: 5px;
transform: translateX(-50%);
}
.stage-label.active { color: var(--mc-primary); font-weight: 600; }
.stage-label.done { color: var(--mc-success, #5a8a5a); font-weight: 600; }

View File

@ -138,15 +138,26 @@
<span class="raw-item-type">{{ raw.sourceType }}</span>
</div>
<div class="raw-item-meta">
<span class="status-badge" :class="raw.processingStatus">
{{ t(`wiki.status.${raw.processingStatus}`) }}
<span
class="status-badge"
:class="cancellingIds.has(raw.id) && raw.processingStatus === 'processing' ? 'cancelling' : raw.processingStatus"
>
{{ cancellingIds.has(raw.id) && raw.processingStatus === 'processing'
? t('wiki.status.cancelling')
: t(`wiki.status.${raw.processingStatus}`) }}
</span>
<span v-if="raw.pageCount != null && raw.pageCount > 0" class="page-count-chip">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{{ raw.pageCount }}
</span>
<span
v-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
v-if="raw.processingStatus === 'cancelled'"
class="error-hint" :title="raw.errorMessage || ''"
>
{{ t('wiki.cancelledHint') }}
</span>
<span
v-else-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
class="error-hint" :title="raw.errorMessage"
>
{{ raw.errorMessage }}
@ -154,7 +165,25 @@
</div>
<div class="raw-item-actions">
<button
v-if="raw.processingStatus === 'partial'"
v-if="raw.processingStatus === 'processing' && !cancellingIds.has(raw.id)"
class="btn-icon btn-icon-danger" :title="t('wiki.cancel')"
@click="cancelRaw(raw.id)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="6" y1="18" x2="18" y2="6"/>
</svg>
</button>
<button
v-else-if="raw.processingStatus === 'processing' && cancellingIds.has(raw.id)"
class="btn-icon btn-icon-cancelling" :title="t('wiki.cancelling')" disabled
>
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M21 12a9 9 0 1 1-6.22-8.56"/>
</svg>
</button>
<button
v-else-if="raw.processingStatus === 'partial'"
class="btn-icon btn-icon-resume" :title="t('wiki.resume')"
@click="reprocess(raw.id)"
>
@ -163,7 +192,7 @@
</svg>
</button>
<button
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed' || raw.processingStatus === 'cancelled'"
class="btn-icon" :title="t('wiki.reprocess')"
@click="reprocess(raw.id)"
>
@ -388,6 +417,14 @@ let jobPoller: ReturnType<typeof setTimeout> | null = null
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'partial', 'cancelled'])
// Local optimistic state: rows the user has just clicked "cancel" on.
// Backend cancellation is observed at the next abort checkpoint (which can
// take 10+ seconds while a route-phase LLM call is in flight), so without
// this set the click looks unresponsive the button stays the same and
// the badge keeps reading "". Cleared as soon as the row's status
// transitions out of "processing" via the next fetchRawMaterials.
const cancellingIds = ref(new Set<number>())
async function pollJobs() {
if (!store.currentKB) return
const kbId = store.currentKB.id
@ -426,6 +463,22 @@ watch(hasProcessing, (active) => {
else if (jobPoller) { clearTimeout(jobPoller); jobPoller = null }
}, { immediate: true })
// Clear the optimistic "cancelling" flag for any row that has left the
// 'processing' state the backend has written the terminal status, so
// the badge and action buttons can now reflect reality.
watch(() => store.rawMaterials, (rows) => {
if (cancellingIds.value.size === 0) return
const next = new Set(cancellingIds.value)
for (const r of rows) {
if (r.processingStatus !== 'processing' && next.has(r.id)) {
next.delete(r.id)
}
}
if (next.size !== cancellingIds.value.size) {
cancellingIds.value = next
}
}, { deep: true })
async function handleLocalRepair(rawId: number) {
if (!store.currentKB) return
// For local repair, we'd need a page slug. For now, reprocess the raw material.
@ -556,6 +609,30 @@ async function deleteRaw(rawId: number) {
await store.fetchRawMaterials(store.currentKB.id)
}
async function cancelRaw(rawId: number) {
if (!store.currentKB) return
const kbId = store.currentKB.id
// Optimistic flag drives the spinner button and "" badge text
// so the click is visibly registered even if the pipeline is currently
// mid-LLM call and won't reach its next abort checkpoint for several seconds.
cancellingIds.value.add(rawId)
try {
await wikiApi.cancelRaw(kbId, rawId)
} catch (e) {
// Roll back the optimistic state if the call itself failed (auth /
// network error). Without this the button would stay stuck in the
// cancelling state forever.
cancellingIds.value.delete(rawId)
throw e
}
// Re-fetch periodically so the row's status flips from 'processing' to
// 'cancelled' as soon as the pipeline observes the flag and writes its
// terminal status at which point the watch below clears the flag.
await store.fetchRawMaterials(kbId)
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
setTimeout(() => { store.fetchRawMaterials(kbId) }, 15000)
}
async function downloadRaw(raw: { id: number; title?: string }) {
if (!store.currentKB) return
try {
@ -738,6 +815,12 @@ async function handleScanDir() {
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
.status-badge.partial { background: rgba(217, 119, 87, 0.15); color: var(--mc-primary); }
.status-badge.failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
.status-badge.cancelled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.status-badge.cancelling { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.btn-icon.btn-icon-cancelling { cursor: default; opacity: 0.7; }
.btn-icon.btn-icon-cancelling .spinner { animation: rmp-spin 0.9s linear infinite; }
@keyframes rmp-spin { to { transform: rotate(360deg); } }
/* Process button */
.process-btn { width: 100%; justify-content: center; margin-top: 16px; }