From f04ea58c8d9f06d90e18ee6537988a3159ec7b43 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 11:25:44 +0800 Subject: [PATCH] feat(wiki): cancel a running transformation and re-run any past run --- .gitignore | 1 + .../WikiTransformationController.java | 16 +++++ .../service/WikiTransformationExecutor.java | 37 ++++++++++++ mateclaw-ui/src/api/index.ts | 2 + mateclaw-ui/src/i18n/locales/en-US.ts | 6 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 6 ++ .../Wiki/components/TransformationsPanel.vue | 60 ++++++++++++++++++- 7 files changed, 125 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index dd12de0d..2e20f10d 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,7 @@ CLAUDE.md # Codex CLI local artifacts .codex/ +.superpowers/ # Sync tooling local state (generated each run; report is intentionally tracked) scripts/.*-sync-state.json diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java index d2d78c91..d973ceae 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java @@ -166,6 +166,22 @@ public class WikiTransformationController { return R.fail("One of rawId / kbId / transformationId is required"); } + @RequireWorkspaceRole("member") + @Operation(summary = "Cancel a still-running transformation run", + description = "Marks the run as cancelled so the executor drops the eventual LLM output. " + + "The HTTP request to the model continues server-side because most providers " + + "do not support cancellation; this endpoint affects bookkeeping only.") + @PostMapping("/runs/{runId}/cancel") + public R cancelRun(@PathVariable Long runId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + WikiTransformationRunEntity run = transformationService.getRun(runId); + if (run == null) return R.fail("Run not found"); + verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L); + boolean cancelled = executor.cancelRun(runId); + if (!cancelled) return R.fail("Run is not running"); + return R.ok(); + } + @RequireWorkspaceRole("member") @Operation(summary = "Save a completed run's output as a synthesis wiki page", description = "Idempotent: re-saving an already-saved run updates the same page slug.") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index b2ac3f4f..fa4c16a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -150,6 +150,16 @@ public class WikiTransformationExecutor { if (output == null || output.isBlank()) { throw new IllegalStateException("LLM returned empty output"); } + // Honour a mid-flight cancel: the cancel endpoint flipped the run + // row to 'cancelled' while the LLM was still working. Drop the + // output and stop here rather than overwrite the cancelled state. + WikiTransformationRunEntity current = transformationService.getRun(run.getId()); + if (current != null && "cancelled".equalsIgnoreCase(current.getStatus())) { + log.info("[WikiTransformation] run={} was cancelled mid-flight; discarding {} chars of LLM output", + run.getId(), output.length()); + return current; + } + run.setOutput(output); run.setStatus("completed"); run.setCompletedAt(LocalDateTime.now()); @@ -175,6 +185,12 @@ public class WikiTransformationExecutor { run.getId(), transformation.getName(), rawId, raw.getKbId(), run.getDurationMs(), run.getOutputPageId()); } catch (Exception e) { + WikiTransformationRunEntity current = transformationService.getRun(run.getId()); + if (current != null && "cancelled".equalsIgnoreCase(current.getStatus())) { + log.info("[WikiTransformation] run={} was cancelled before failure could be recorded ({})", + run.getId(), e.getMessage()); + return current; + } run.setStatus("failed"); String msg = e.getMessage(); run.setError(msg == null ? e.getClass().getSimpleName() : msg); @@ -187,6 +203,27 @@ public class WikiTransformationExecutor { return run; } + /** + * Mark a still-active run as cancelled. The blocked LLM call (if any) + * continues server-side but its eventual output is dropped by the + * post-call check in {@link #runOnRawSync}. + */ + public boolean cancelRun(Long runId) { + if (runId == null) return false; + WikiTransformationRunEntity run = transformationService.getRun(runId); + if (run == null) return false; + String status = run.getStatus(); + if (!"pending".equalsIgnoreCase(status) && !"running".equalsIgnoreCase(status)) { + return false; + } + run.setStatus("cancelled"); + run.setCompletedAt(LocalDateTime.now()); + if (run.getError() == null) run.setError("Cancelled by user"); + transformationService.updateRun(run); + log.info("[WikiTransformation] run={} cancelled by user", runId); + return true; + } + private String renderTemplate(String template, WikiRawMaterialEntity raw, String inputText) { if (template == null) return ""; String result = template; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 461cccdc..c33e01bd 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -715,6 +715,8 @@ export const wikiApi = { http.delete(`/wiki/transformations/runs/${runId}`), saveTransformationRunAsPage: (runId: number) => http.post(`/wiki/transformations/runs/${runId}/save-as-page`), + cancelTransformationRun: (runId: number) => + http.post(`/wiki/transformations/runs/${runId}/cancel`), } // ==================== Workspace (Team) ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 27e11789..4b0dfe33 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1838,6 +1838,12 @@ export default { saveBtn: 'Save', cancelBtn: 'Cancel', runs: 'Run history', + rerunBtn: 'Re-run', + rerunning: 'Re-running…', + cancelRunBtn: 'Cancel', + cancelling: 'Cancelling…', + cancelDone: 'Cancelled', + cancelFailed: 'Cancel failed', runOn: 'Ran on', runStatus: 'Status', runDuration: 'Duration', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 98be6463..682b843d 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1850,6 +1850,12 @@ export default { saveBtn: '保存', cancelBtn: '取消', runs: '运行历史', + rerunBtn: '重新运行', + rerunning: '重新运行中…', + cancelRunBtn: '取消', + cancelling: '取消中…', + cancelDone: '已取消', + cancelFailed: '取消失败', runOn: '运行于', runStatus: '状态', runDuration: '耗时', diff --git a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue index a336fb1b..2b17a47c 100644 --- a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue @@ -109,11 +109,36 @@ > {{ t('wiki.transformations.openPage') }} +
{{ run.output }}
-
{{ run.error }}
-
{{ t('wiki.transformations.running') }}
+ + @@ -242,7 +267,7 @@ interface WikiTransformationRun { rawId: number | null pageId: number | null inputKind: string - status: 'pending' | 'running' | 'completed' | 'failed' + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' output: string | null error: string | null durationMs: number | null @@ -268,6 +293,8 @@ const editorOpen = ref(false) const editing = ref(null) const saving = ref(false) const savingRunId = ref(null) +const cancellingRunId = ref(null) +const rerunningRunId = ref(null) interface ModelOption { id: number; name: string; provider: string; modelName: string } const availableModels = ref([]) @@ -487,6 +514,32 @@ async function onSaveRunAsPage(tpl: WikiTransformation, run: WikiTransformationR } } +async function onCancelRun(tpl: WikiTransformation, run: WikiTransformationRun) { + cancellingRunId.value = run.id + try { + await wikiApi.cancelTransformationRun(run.id) + ElMessage.success(t('wiki.transformations.cancelDone')) + await loadRunsFor(tpl.id) + } catch (e: any) { + ElMessage.error(e?.message ?? t('wiki.transformations.cancelFailed')) + } finally { + cancellingRunId.value = null + } +} + +async function onRerun(tpl: WikiTransformation, run: WikiTransformationRun) { + if (!run.rawId) return + rerunningRunId.value = run.id + try { + await wikiApi.applyTransformation(tpl.id, run.rawId, true) + await loadRunsFor(tpl.id) + } catch (e: any) { + ElMessage.error(e?.message ?? t('wiki.transformations.runFailed')) + } finally { + rerunningRunId.value = null + } +} + async function onOpenSavedPage(run: WikiTransformationRun) { if (!run.outputPageId || !store.currentKB) return try { @@ -640,6 +693,7 @@ onMounted(async () => { .run-status--completed { background: var(--el-color-success-light-9); color: var(--el-color-success); } .run-status--failed { background: var(--el-color-danger-light-9); color: var(--el-color-danger); } .run-status--running, .run-status--pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); } +.run-status--cancelled { background: var(--mc-bg-muted); color: var(--mc-text-tertiary); } .run-meta { color: var(--mc-text-tertiary); } .run-output { margin-top: 6px;