diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 9f1595db..c05a8545 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1842,6 +1842,10 @@ export default {
promptHelp: 'Supports {input_text} and {title} placeholders',
saveBtn: 'Save',
cancelBtn: 'Cancel',
+ compareBtn: 'Compare selected',
+ compareHint: 'Pick 1 more run to compare',
+ compareCheckTip: 'Tick 2 completed runs to compare side-by-side',
+ compareTitle: 'Compare runs',
aggregateBtn: 'Aggregate all runs',
aggregating: 'Aggregating…',
aggregateDone: 'Aggregate page produced',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 355aa3bf..ac60df27 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1854,6 +1854,10 @@ export default {
promptHelp: '可使用 {input_text} 和 {title} 占位符',
saveBtn: '保存',
cancelBtn: '取消',
+ compareBtn: '对比所选',
+ compareHint: '再选 1 条即可对比',
+ compareCheckTip: '勾选 2 条已完成运行可并排对比',
+ compareTitle: '运行结果对比',
aggregateBtn: '聚合所有运行',
aggregating: '聚合中…',
aggregateDone: '聚合页已生成',
diff --git a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
index c6073d15..62b125eb 100644
--- a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
+++ b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
@@ -87,9 +87,29 @@
-
{{ t('wiki.transformations.runs') }}
+
+
{{ t('wiki.transformations.runs') }}
+
+
+ {{ t('wiki.transformations.compareHint') }}
+
+
+
{{ run.status }}
{{ rawTitleFor(run.rawId) }}
@@ -260,6 +280,39 @@
+
+
+
+
+
+
{{ t('wiki.transformations.compareTitle') }}
+
+
+
+
+
+ Run #{{ run?.id }}
+
+ {{ formatTimestamp(run?.completedAt || run?.startedAt || run?.createTime || null) }}
+
+ {{ formatDuration(run?.durationMs ?? null) }}
+
+
+ {{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓
+
+
+
+ {{ run?.inputKind === 'page' ? 'page' : 'raw' }} · {{ run?.rawId != null ? rawTitleFor(run.rawId) : ('page#' + run?.pageId) }}
+
+
+ {{ run?.output ?? '' }}
+
+
+
+
+
+
+
@@ -325,6 +378,10 @@ const savingRunId = ref(null)
const cancellingRunId = ref(null)
const rerunningRunId = ref(null)
const aggregatingTemplateId = ref(null)
+// Map of templateId → up-to-2 run IDs the user has ticked for side-by-side compare.
+const selectedForCompare = ref>({})
+const compareOpen = ref(false)
+const compareRuns = ref<(WikiTransformationRun | undefined)[]>([])
interface ModelOption { id: number; name: string; provider: string; modelName: string }
const availableModels = ref([])
@@ -557,6 +614,43 @@ async function onSaveRunAsPage(tpl: WikiTransformation, run: WikiTransformationR
}
}
+function isSelectedForCompare(tplId: number, runId: number): boolean {
+ return (selectedForCompare.value[tplId] || []).includes(runId)
+}
+
+function toggleCompareSelect(tplId: number, runId: number) {
+ const existing = selectedForCompare.value[tplId] || []
+ if (existing.includes(runId)) {
+ selectedForCompare.value[tplId] = existing.filter((id) => id !== runId)
+ return
+ }
+ if (existing.length >= 2) {
+ // Sliding window: drop the oldest selection to make room for the new pick.
+ selectedForCompare.value[tplId] = [existing[1], runId]
+ } else {
+ selectedForCompare.value[tplId] = [...existing, runId]
+ }
+}
+
+function openCompare(tpl: WikiTransformation) {
+ const ids = selectedForCompare.value[tpl.id] || []
+ if (ids.length !== 2) return
+ const runs = runsByTemplate.value[tpl.id] || []
+ // Order chronologically (older first) so left = before, right = after.
+ const picked = ids
+ .map((id) => runs.find((r) => r.id === id))
+ .filter((r): r is WikiTransformationRun => !!r)
+ .sort((a, b) => new Date(a.createTime).getTime() - new Date(b.createTime).getTime())
+ if (picked.length !== 2) return
+ compareRuns.value = picked
+ compareOpen.value = true
+}
+
+function closeCompare() {
+ compareOpen.value = false
+ compareRuns.value = []
+}
+
async function onAggregate(tpl: WikiTransformation) {
if (!store.currentKB) return
aggregatingTemplateId.value = tpl.id
@@ -736,7 +830,11 @@ onMounted(async () => {
}
.runs-list { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px dashed var(--mc-border-light); }
-.runs-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mc-text-tertiary); }
+.runs-head-row { display: flex; align-items: center; gap: 10px; }
+.runs-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mc-text-tertiary); flex: 1; }
+.btn-compare { font-size: 12px; padding: 4px 12px; }
+.compare-hint { font-size: 11px; color: var(--mc-text-tertiary); }
+.compare-check { margin-right: 6px; accent-color: var(--mc-primary); cursor: pointer; }
.run-item summary {
cursor: pointer;
display: flex;
@@ -815,6 +913,40 @@ onMounted(async () => {
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
+.modal--compare { width: min(1200px, 96vw); }
+.compare-body { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px 16px; overflow-y: auto; }
+.compare-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
+.compare-col-head {
+ background: var(--mc-bg-muted);
+ border: 1px solid var(--mc-border-light);
+ border-radius: 10px;
+ padding: 10px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.compare-col-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
+.compare-col-meta { font-size: 11px; color: var(--mc-text-secondary); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.compare-col-source { font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); }
+.compare-col-output {
+ flex: 1;
+ margin: 0;
+ padding: 12px 14px;
+ background: var(--mc-bg);
+ border: 1px solid var(--mc-border-light);
+ border-radius: 10px;
+ font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace);
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--mc-text-primary);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow: auto;
+ min-height: 360px;
+}
+@media (max-width: 900px) {
+ .compare-body { grid-template-columns: 1fr; }
+}
.modal-head {
display: flex;
justify-content: space-between;