From 0357c9891dc53e8e1a982f3a80615c61a09d1874 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:03:10 +0800 Subject: [PATCH] feat(wiki): user-initiated cancel for in-progress raw material processing (#72) --- .../mate/wiki/controller/WikiController.java | 17 ++++ .../wiki/model/WikiRawMaterialEntity.java | 11 ++- .../wiki/service/WikiProcessingService.java | 86 +++++++++++++---- .../wiki/service/WikiRawMaterialService.java | 43 +++++++++ .../h2/V95__wiki_raw_material_cancel.sql | 6 ++ .../mysql/V95__wiki_raw_material_cancel.sql | 9 ++ mateclaw-ui/src/api/index.ts | 2 + mateclaw-ui/src/i18n/locales/en-US.ts | 5 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 5 + .../src/views/Wiki/components/JobStageBar.vue | 44 ++++++++- .../Wiki/components/RawMaterialPanel.vue | 93 ++++++++++++++++++- 11 files changed, 290 insertions(+), 31 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V95__wiki_raw_material_cancel.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V95__wiki_raw_material_cancel.sql diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index b765f46d..0de20d85 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -300,6 +300,23 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("member") + @Operation(summary = "请求取消正在进行的处理(仅在 processing 状态有效)") + @PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/cancel") + public R 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") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java index 06bcdbba..f623ccdf 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 9ea5bf25..449f3e96 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -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; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index f71f158b..1d8f005b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -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 两阶段消化的进度字段。 *

@@ -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); } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V95__wiki_raw_material_cancel.sql b/mateclaw-server/src/main/resources/db/migration/h2/V95__wiki_raw_material_cancel.sql new file mode 100644 index 00000000..7de8846a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V95__wiki_raw_material_cancel.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V95__wiki_raw_material_cancel.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V95__wiki_raw_material_cancel.sql new file mode 100644 index 00000000..3a5e5130 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V95__wiki_raw_material_cancel.sql @@ -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; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 6d1c7a9c..a1ec953d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, { responseType: 'blob', diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 915c0d65..30f8c4d6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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…', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3ff982e3..5350ecdb 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '准备中…', diff --git a/mateclaw-ui/src/views/Wiki/components/JobStageBar.vue b/mateclaw-ui/src/views/Wiki/components/JobStageBar.vue index be976b7e..62695660 100644 --- a/mateclaw-ui/src/views/Wiki/components/JobStageBar.vue +++ b/mateclaw-ui/src/views/Wiki/components/JobStageBar.vue @@ -15,9 +15,15 @@

- - {{ t(`wiki.jobStage.${stage.key}`) }} - +
+ + {{ t(`wiki.jobStage.${stage.key}`) }} + +
@@ -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; } diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index 69c101a8..bff2bb28 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -138,15 +138,26 @@ {{ raw.sourceType }}
- - {{ t(`wiki.status.${raw.processingStatus}`) }} + + {{ cancellingIds.has(raw.id) && raw.processingStatus === 'processing' + ? t('wiki.status.cancelling') + : t(`wiki.status.${raw.processingStatus}`) }} {{ raw.pageCount }} + {{ t('wiki.cancelledHint') }} + + {{ raw.errorMessage }} @@ -154,7 +165,25 @@
+ +