feat(wiki): cancel a running transformation and re-run any past run

This commit is contained in:
matevip 2026-05-12 11:25:44 +08:00
parent ef6ad1b91a
commit f04ea58c8d
7 changed files with 125 additions and 3 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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<Void> 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.")

View File

@ -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;

View File

@ -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) ====================

View File

@ -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',

View File

@ -1850,6 +1850,12 @@ export default {
saveBtn: '保存',
cancelBtn: '取消',
runs: '运行历史',
rerunBtn: '重新运行',
rerunning: '重新运行中…',
cancelRunBtn: '取消',
cancelling: '取消中…',
cancelDone: '已取消',
cancelFailed: '取消失败',
runOn: '运行于',
runStatus: '状态',
runDuration: '耗时',

View File

@ -109,11 +109,36 @@
>
{{ t('wiki.transformations.openPage') }}
</button>
<button class="btn-secondary" :disabled="rerunningRunId === run.id" @click="onRerun(tpl, run)">
{{ rerunningRunId === run.id
? t('wiki.transformations.rerunning')
: t('wiki.transformations.rerunBtn') }}
</button>
</div>
<div class="run-output">{{ run.output }}</div>
</template>
<div v-else-if="run.status === 'failed'" class="run-error">{{ run.error }}</div>
<div v-else class="run-output run-output--muted">{{ t('wiki.transformations.running') }}</div>
<template v-else-if="run.status === 'failed' || run.status === 'cancelled'">
<div class="run-actions">
<button class="btn-secondary" :disabled="rerunningRunId === run.id" @click="onRerun(tpl, run)">
{{ rerunningRunId === run.id
? t('wiki.transformations.rerunning')
: t('wiki.transformations.rerunBtn') }}
</button>
</div>
<div class="run-error">{{ run.error }}</div>
</template>
<template v-else>
<div class="run-actions">
<button class="btn-secondary btn-danger"
:disabled="cancellingRunId === run.id"
@click="onCancelRun(tpl, run)">
{{ cancellingRunId === run.id
? t('wiki.transformations.cancelling')
: t('wiki.transformations.cancelRunBtn') }}
</button>
</div>
<div class="run-output run-output--muted">{{ t('wiki.transformations.running') }}</div>
</template>
</details>
</div>
</article>
@ -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<WikiTransformation | null>(null)
const saving = ref(false)
const savingRunId = ref<number | null>(null)
const cancellingRunId = ref<number | null>(null)
const rerunningRunId = ref<number | null>(null)
interface ModelOption { id: number; name: string; provider: string; modelName: string }
const availableModels = ref<ModelOption[]>([])
@ -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;