mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): user-initiated cancel for in-progress raw material processing (#72)
This commit is contained in:
parent
274aeb7695
commit
76f5b3ca94
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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',
|
||||
|
||||
@ -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…',
|
||||
|
||||
@ -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: '准备中…',
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user