mateclaw/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
倪程伟 28f2ba973d feat(wiki): honour KB pageType profile in transformations, agent pages & UI
Wiki page classification was only profile-aware in the main ingest pipeline.
Transformation outputs hard-coded "synthesis", agent-created pages were left
untyped, and the frontend hard-coded the built-in ten types for ordering,
colouring and labels — so custom/synthesis types sank to the bottom, rendered
grey and showed raw keys.

Backend:
- Add nullable target_page_type column to mate_wiki_transformation (V142,
  mysql + h2) plus the entity field and CRUD normalization (blank = use
  profile fallbackType; membership validated at save time, not edit time).
- Route transformation single-run + KB-aggregate page saves through
  WikiPageTypeProfileService.normalizePageType so output joins the KB
  classification; agent wiki_create_page now lands on the profile fallbackType
  instead of an untyped page.

Frontend:
- Load + parse the KB pageType profile into the wiki store (order, labels,
  fallbackType) on KB select / refresh.
- New useWikiPageType composable: profile-driven label (3-tier fallback) and
  colour (built-in fixed + deterministic hash palette for custom types).
- Sidebar grouping order, graph colouring, node panel, graph filter and the
  page header badge now follow the profile; transformation editor gains a
  target-type dropdown sourced from the profile when output target is a page.

Refs #292
2026-06-08 21:02:50 +08:00

1060 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="transformations-panel">
<header class="panel-header">
<div class="header-text">
<h3 class="panel-title">{{ t('wiki.transformations.title') }}</h3>
<p class="panel-desc">{{ t('wiki.transformations.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-primary" :disabled="!store.currentKB" @click="openCreate">
+ {{ t('wiki.transformations.createBtn') }}
</button>
</div>
</header>
<div v-if="loading" class="state-row">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="error" class="state-row state-row--error">
<el-icon><WarningFilled /></el-icon>
<span>{{ error }}</span>
<button class="btn-secondary" @click="loadAll">{{ t('common.retry', 'Retry') }}</button>
</div>
<div v-else-if="templates.length === 0" class="state-row state-row--empty">
<span>{{ t('wiki.transformations.empty') }}</span>
</div>
<div v-else class="templates-list">
<article v-for="tpl in templates" :key="tpl.id" class="template-card">
<div class="template-head">
<div class="template-title">
<span class="template-name">{{ tpl.title }}</span>
<span class="template-handle">{{ tpl.name }}</span>
</div>
<div class="template-flags">
<span class="flag" :class="{ 'flag--on': tpl.applyDefault }">
{{ tpl.applyDefault ? t('wiki.transformations.applyDefaultOn') : t('wiki.transformations.applyDefaultOff') }}
</span>
<span v-if="tpl.outputTarget === 'page'" class="flag flag--on">
{{ t('wiki.transformations.outputTargetPageBadge') }}
</span>
<span v-if="tpl.outputFormat === 'json'" class="flag flag--scope">
{{ t('wiki.transformations.outputFormatJsonBadge') }}
</span>
<span v-if="tpl.outputSchema" class="flag flag--scope">
{{ t('wiki.transformations.outputSchemaBadge') }}
</span>
<span v-if="tpl.modelId" class="flag flag--scope">
{{ modelLabelFor(tpl.modelId) }}
</span>
<span class="flag" :class="{ 'flag--muted': tpl.enabled === false }">
{{ tpl.enabled === false ? t('wiki.transformations.disabled') : t('wiki.transformations.enabled') }}
</span>
<span class="flag flag--scope">
{{ tpl.kbId ? t('wiki.transformations.scopeKb') : t('wiki.transformations.scopeWorkspace') }}
</span>
</div>
</div>
<p v-if="tpl.description" class="template-desc">{{ tpl.description }}</p>
<div class="template-actions">
<select
class="raw-select"
v-model="selectedRawByTemplate[tpl.id]"
>
<option :value="null" disabled>{{ t('wiki.transformations.pickRaw') }}</option>
<option v-for="r in completedRaws" :key="r.id" :value="r.id">
{{ r.title || `raw#${r.id}` }}
</option>
</select>
<button
class="btn-primary"
:disabled="!selectedRawByTemplate[tpl.id] || runningTemplateId === tpl.id"
@click="onApply(tpl)"
>
<span v-if="runningTemplateId === tpl.id">{{ t('wiki.transformations.running') }}</span>
<span v-else>{{ t('wiki.transformations.runApply') }}</span>
</button>
<button class="btn-secondary"
:disabled="aggregatingTemplateId === tpl.id"
@click="onAggregate(tpl)">
{{ aggregatingTemplateId === tpl.id
? t('wiki.transformations.aggregating')
: t('wiki.transformations.aggregateBtn') }}
</button>
<button class="btn-secondary" @click="openEdit(tpl)">{{ t('wiki.transformations.editBtn') }}</button>
<button class="btn-secondary btn-danger" @click="onDelete(tpl)">{{ t('wiki.transformations.deleteBtn') }}</button>
</div>
<div v-if="runsByTemplate[tpl.id]?.length" class="runs-list">
<div class="runs-head-row">
<div class="runs-title">{{ t('wiki.transformations.runs') }}</div>
<button
v-if="selectedForCompare[tpl.id]?.length === 2"
class="btn-secondary btn-compare"
@click="openCompare(tpl)"
>
{{ t('wiki.transformations.compareBtn') }} ({{ selectedForCompare[tpl.id].length }})
</button>
<span v-else-if="selectedForCompare[tpl.id]?.length === 1" class="compare-hint">
{{ t('wiki.transformations.compareHint') }}
</span>
</div>
<details v-for="run in runsByTemplate[tpl.id]" :key="run.id" class="run-item">
<summary>
<input
v-if="run.status === 'completed'"
type="checkbox"
class="compare-check"
:checked="isSelectedForCompare(tpl.id, run.id)"
@click.stop="toggleCompareSelect(tpl.id, run.id)"
:title="t('wiki.transformations.compareCheckTip')"
/>
<span class="run-status" :class="`run-status--${run.status}`">{{ run.status }}</span>
<span class="run-meta">
{{ rawTitleFor(run.rawId) }}
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
· {{ formatDuration(run.durationMs) }}
<span v-if="run.totalTokens" class="run-tokens">
· {{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓
</span>
</span>
<span v-if="run.outputPageId" class="run-saved-badge">
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
</span>
</summary>
<template v-if="run.status === 'completed'">
<div class="run-actions">
<button
v-if="!run.outputPageId"
class="btn-secondary"
:disabled="savingRunId === run.id"
@click="onSaveRunAsPage(tpl, run)"
>
{{ savingRunId === run.id
? t('wiki.transformations.saving')
: t('wiki.transformations.saveAsPageBtn') }}
</button>
<button
v-else
class="btn-secondary"
@click="onOpenSavedPage(run)"
>
{{ 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>
<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>
</div>
<!-- Create / edit modal -->
<div v-if="editorOpen" class="modal-overlay" @click.self="closeEditor">
<div class="modal">
<div class="modal-head">
<h3>{{ editing ? t('wiki.transformations.editBtn') : t('wiki.transformations.createBtn') }}</h3>
<button class="modal-close" @click="closeEditor">×</button>
</div>
<div class="modal-body">
<label class="field">
<span class="field-label">{{ t('wiki.transformations.name') }}</span>
<input
v-model="form.name"
:placeholder="t('wiki.transformations.namePlaceholder')"
:disabled="!!editing"
class="field-input"
/>
<span class="field-hint">{{ t('wiki.transformations.nameHelp') }}</span>
</label>
<label class="field">
<span class="field-label">{{ t('wiki.transformations.title2') }}</span>
<input
v-model="form.title"
:placeholder="t('wiki.transformations.titlePlaceholder')"
class="field-input"
/>
</label>
<label class="field">
<span class="field-label">{{ t('wiki.transformations.description') }}</span>
<input
v-model="form.description"
:placeholder="t('wiki.transformations.descriptionPlaceholder')"
class="field-input"
/>
</label>
<label class="field">
<span class="field-label">{{ t('wiki.transformations.modelLabel') }}</span>
<select v-model="form.modelId" class="field-input">
<option :value="null">{{ t('wiki.transformations.modelDefault') }}</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.name }} <span v-if="m.provider"> · {{ m.provider }}</span>
</option>
</select>
<span class="field-hint">{{ t('wiki.transformations.modelHelp') }}</span>
</label>
<label class="field">
<span class="field-label">{{ t('wiki.transformations.prompt') }}</span>
<textarea
v-model="form.promptTemplate"
class="field-textarea"
rows="10"
></textarea>
<span class="field-hint">{{ t('wiki.transformations.promptHelp') }}</span>
</label>
<div class="field-row">
<label class="check">
<input type="checkbox" v-model="form.applyDefault" />
<span>{{ t('wiki.transformations.applyDefault') }}</span>
</label>
<label class="check">
<input type="checkbox" v-model="form.enabled" />
<span>{{ t('wiki.transformations.enabled') }}</span>
</label>
</div>
<fieldset class="field field--group">
<legend class="field-label">{{ t('wiki.transformations.outputTargetLabel') }}</legend>
<label class="radio-row">
<input type="radio" value="none" v-model="form.outputTarget" />
<span>{{ t('wiki.transformations.outputTargetNone') }}</span>
</label>
<label class="radio-row">
<input type="radio" value="page" v-model="form.outputTarget" />
<span>{{ t('wiki.transformations.outputTargetPage') }}</span>
</label>
</fieldset>
<label v-if="form.outputTarget === 'page'" class="field">
<span class="field-label">{{ t('wiki.transformations.targetPageType') }}</span>
<select v-model="form.targetPageType" class="field-input">
<option value="">{{ t('wiki.transformations.targetPageTypeAuto') }}</option>
<option v-for="opt in pageTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span class="field-hint">{{ t('wiki.transformations.targetPageTypeHelp') }}</span>
</label>
<fieldset class="field field--group">
<legend class="field-label">{{ t('wiki.transformations.outputFormatLabel') }}</legend>
<label class="radio-row">
<input type="radio" value="markdown" v-model="form.outputFormat" />
<span>{{ t('wiki.transformations.outputFormatMarkdown') }}</span>
</label>
<label class="radio-row">
<input type="radio" value="json" v-model="form.outputFormat" />
<span>{{ t('wiki.transformations.outputFormatJson') }}</span>
</label>
</fieldset>
<label v-if="form.outputFormat === 'json'" class="field">
<span class="field-label">{{ t('wiki.transformations.outputSchemaLabel') }}</span>
<textarea
v-model="form.outputSchema"
class="field-textarea"
rows="8"
:placeholder="t('wiki.transformations.outputSchemaPlaceholder')"
></textarea>
<span class="field-hint">{{ t('wiki.transformations.outputSchemaHelp') }}</span>
</label>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="closeEditor">{{ t('wiki.transformations.cancelBtn') }}</button>
<button class="btn-primary" :disabled="saving" @click="onSave">
{{ saving ? t('common.loading') : t('wiki.transformations.saveBtn') }}
</button>
</div>
</div>
</div>
<!-- Compare modal: shows two completed runs side-by-side. -->
<div v-if="compareOpen" class="modal-overlay" @click.self="closeCompare">
<div class="modal modal--compare">
<div class="modal-head">
<h3>{{ t('wiki.transformations.compareTitle') }}</h3>
<button class="modal-close" @click="closeCompare">×</button>
</div>
<div class="modal-body compare-body">
<article v-for="(run, idx) in compareRuns" :key="run?.id ?? idx" class="compare-col">
<header class="compare-col-head">
<div class="compare-col-title">Run #{{ run?.id }}</div>
<div class="compare-col-meta">
<span>{{ formatTimestamp(run?.completedAt || run?.startedAt || run?.createTime || null) }}</span>
<span class="dot"></span>
<span>{{ formatDuration(run?.durationMs ?? null) }}</span>
<span v-if="run?.totalTokens" class="dot"></span>
<span v-if="run?.totalTokens">
{{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓
</span>
</div>
<div class="compare-col-source">
{{ run?.inputKind === 'page' ? 'page' : 'raw' }} · {{ run?.rawId != null ? rawTitleFor(run.rawId) : ('page#' + run?.pageId) }}
</div>
</header>
<pre class="compare-col-output">{{ run?.output ?? '' }}</pre>
</article>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="closeCompare">{{ t('wiki.transformations.cancelBtn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { wikiApi, modelApi } from '@/api/index'
import { useWikiStore, type WikiRawMaterial } from '@/stores/useWikiStore'
interface WikiTransformation {
id: number
kbId: number | null
workspaceId: number
name: string
title: string
description: string | null
promptTemplate: string
applyDefault: boolean
enabled: boolean
modelId: number | null
outputTarget: 'none' | 'page' | null
outputFormat: 'markdown' | 'json' | null
outputSchema: string | null
targetPageType: string | null
}
interface WikiTransformationRun {
id: number
transformationId: number
kbId: number
rawId: number | null
pageId: number | null
inputKind: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
output: string | null
error: string | null
durationMs: number | null
startedAt: string | null
completedAt: string | null
createTime: string
triggeredBy: string
outputPageId: number | null
inputTokens: number | null
outputTokens: number | null
totalTokens: number | null
}
const { t } = useI18n()
const store = useWikiStore()
const templates = ref<WikiTransformation[]>([])
const runsByTemplate = ref<Record<number, WikiTransformationRun[]>>({})
const selectedRawByTemplate = ref<Record<number, number | null>>({})
const runningTemplateId = ref<number | null>(null)
const loading = ref(false)
const error = ref('')
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)
const aggregatingTemplateId = ref<number | null>(null)
// Map of templateId → up-to-2 run IDs the user has ticked for side-by-side compare.
const selectedForCompare = ref<Record<number, number[]>>({})
const compareOpen = ref(false)
const compareRuns = ref<(WikiTransformationRun | undefined)[]>([])
interface ModelOption { id: number; name: string; provider: string; modelName: string }
const availableModels = ref<ModelOption[]>([])
const form = reactive<{
name: string
title: string
description: string
promptTemplate: string
applyDefault: boolean
enabled: boolean
outputTarget: 'none' | 'page'
outputFormat: 'markdown' | 'json'
outputSchema: string
targetPageType: string
modelId: number | null
}>({
name: '',
title: '',
description: '',
promptTemplate: '',
applyDefault: false,
enabled: true,
outputTarget: 'none',
outputFormat: 'markdown',
outputSchema: '',
targetPageType: '',
modelId: null,
})
// Target-type dropdown options come from the active KB's pageType profile.
const pageTypeOptions = computed(() => {
const profile = store.pageTypeProfile
if (!profile) return [] as { value: string; label: string }[]
return profile.order.map((type) => ({
value: type,
label: profile.labels[type] || type,
}))
})
const completedRaws = computed<WikiRawMaterial[]>(() =>
store.rawMaterials.filter(
(r) => r.processingStatus === 'completed' || r.processingStatus === 'partial'
)
)
function rawTitleFor(rawId: number | null): string {
if (rawId == null) return '—'
const r = store.rawMaterials.find((x) => x.id === rawId)
return r?.title || `raw#${rawId}`
}
function modelLabelFor(modelId: number | null | undefined): string {
if (modelId == null) return ''
const m = availableModels.value.find((x) => x.id === modelId)
return m ? m.name : `model#${modelId}`
}
function formatTimestamp(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleString()
}
function formatDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatTokens(n: number | null): string {
if (n == null) return '—'
if (n < 1000) return String(n)
if (n < 1_000_000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'm'
}
async function loadAll() {
if (!store.currentKB) return
loading.value = true
error.value = ''
try {
const resp: any = await wikiApi.listTransformations(store.currentKB.id)
templates.value = resp?.data ?? []
selectedRawByTemplate.value = Object.fromEntries(
templates.value.map((t) => [t.id, selectedRawByTemplate.value[t.id] ?? null])
)
await Promise.all([
ensureModelsLoaded(),
...templates.value.map((tpl) => loadRunsFor(tpl.id)),
])
} catch (e: any) {
error.value = e?.message ?? String(e)
} finally {
loading.value = false
}
}
async function loadRunsFor(templateId: number) {
try {
const resp: any = await wikiApi.listTransformationRuns({ transformationId: templateId, limit: 10 })
runsByTemplate.value[templateId] = resp?.data ?? []
} catch {
runsByTemplate.value[templateId] = []
}
}
async function ensureModelsLoaded() {
if (availableModels.value.length > 0) return
try {
const res: any = await modelApi.listEnabled()
availableModels.value = (res?.data || []).map((m: any) => ({
id: m.id,
name: m.name,
provider: m.provider,
modelName: m.modelName,
}))
} catch {
// Empty list = picker only offers "default".
}
}
function openCreate() {
editing.value = null
form.name = ''
form.title = ''
form.description = ''
form.promptTemplate = 'Summarize the source below as 3 key bullet points and 1 sentence of overall takeaway.\n\nSource:\n{input_text}'
form.applyDefault = false
form.enabled = true
form.outputTarget = 'none'
form.outputFormat = 'markdown'
form.outputSchema = ''
form.targetPageType = ''
form.modelId = null
editorOpen.value = true
ensureModelsLoaded()
}
function openEdit(tpl: WikiTransformation) {
editing.value = tpl
form.name = tpl.name
form.title = tpl.title
form.description = tpl.description || ''
form.promptTemplate = tpl.promptTemplate
form.applyDefault = tpl.applyDefault
form.enabled = tpl.enabled !== false
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
form.outputFormat = tpl.outputFormat === 'json' ? 'json' : 'markdown'
form.outputSchema = tpl.outputSchema || ''
form.targetPageType = tpl.targetPageType || ''
form.modelId = tpl.modelId ?? null
editorOpen.value = true
ensureModelsLoaded()
}
function closeEditor() {
editorOpen.value = false
editing.value = null
}
async function onSave() {
if (!store.currentKB) return
if (!form.name.trim() || !form.title.trim() || !form.promptTemplate.trim()) {
mcToast.warning(t('common.required', 'All fields are required'))
return
}
saving.value = true
try {
// Schema is only persisted when format=json; otherwise we always send
// an empty string so the backend can clear a previously-stored value.
const schemaPayload = form.outputFormat === 'json' ? form.outputSchema.trim() : ''
// Target pageType only applies when output lands as a page; otherwise send
// an empty string so the backend clears any previously-stored value.
const targetPageTypePayload = form.outputTarget === 'page' ? form.targetPageType : ''
if (editing.value) {
// Update path: backend treats `-1` as "clear modelId"; null is skipped.
const updateModelId = form.modelId == null ? -1 : form.modelId
await wikiApi.updateTransformation(editing.value.id, {
title: form.title,
description: form.description,
promptTemplate: form.promptTemplate,
applyDefault: form.applyDefault,
enabled: form.enabled,
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload,
targetPageType: targetPageTypePayload,
modelId: updateModelId,
})
} else {
await wikiApi.createTransformation({
kbId: store.currentKB.id,
name: form.name.trim(),
title: form.title.trim(),
description: form.description,
promptTemplate: form.promptTemplate,
applyDefault: form.applyDefault,
enabled: form.enabled,
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload || null,
targetPageType: targetPageTypePayload || null,
modelId: form.modelId,
})
}
closeEditor()
await loadAll()
} catch (e: any) {
mcToast.error(e?.message ?? String(e))
} finally {
saving.value = false
}
}
async function onDelete(tpl: WikiTransformation) {
if (!confirm(t('wiki.transformations.deleteConfirm'))) return
try {
await wikiApi.deleteTransformation(tpl.id)
await loadAll()
} catch (e: any) {
mcToast.error(e?.message ?? String(e))
}
}
async function onApply(tpl: WikiTransformation) {
const rawId = selectedRawByTemplate.value[tpl.id]
if (!rawId) return
runningTemplateId.value = tpl.id
try {
await wikiApi.applyTransformation(tpl.id, rawId, true)
await loadRunsFor(tpl.id)
} catch (e: any) {
mcToast.error(e?.message ?? t('wiki.transformations.runFailed'))
} finally {
runningTemplateId.value = null
}
}
async function onSaveRunAsPage(tpl: WikiTransformation, run: WikiTransformationRun) {
if (run.status !== 'completed' || !run.output) return
savingRunId.value = run.id
try {
const resp: any = await wikiApi.saveTransformationRunAsPage(run.id)
const payload = resp?.data ?? {}
if (payload.pageId) {
run.outputPageId = payload.pageId
}
mcToast.success(t('wiki.transformations.saveAsPageDone'))
// Refresh the page list in the wiki store so the new page is visible in
// the sidebar / search results without a manual reload.
if (store.currentKB) {
await store.fetchPages(store.currentKB.id)
}
await loadRunsFor(tpl.id)
} catch (e: any) {
mcToast.error(e?.message ?? t('wiki.transformations.saveAsPageFailed'))
} finally {
savingRunId.value = null
}
}
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
try {
const resp: any = await wikiApi.aggregateTransformation(tpl.id, store.currentKB.id)
const payload = resp?.data ?? {}
if (payload && payload.pageId) {
mcToast.success(`${t('wiki.transformations.aggregateDone')} · ${payload.sourcesUsed} sources`)
// Refresh the page list in the wiki store so the new aggregate page
// shows up in the sidebar without a manual reload.
try { await store.fetchPages(store.currentKB.id) } catch {}
} else {
mcToast.info(t('wiki.transformations.aggregateNoRuns'))
}
} catch (e: any) {
mcToast.error(e?.message ?? t('wiki.transformations.aggregateFailed'))
} finally {
aggregatingTemplateId.value = null
}
}
async function onCancelRun(tpl: WikiTransformation, run: WikiTransformationRun) {
cancellingRunId.value = run.id
try {
await wikiApi.cancelTransformationRun(run.id)
mcToast.success(t('wiki.transformations.cancelDone'))
await loadRunsFor(tpl.id)
} catch (e: any) {
mcToast.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) {
mcToast.error(e?.message ?? t('wiki.transformations.runFailed'))
} finally {
rerunningRunId.value = null
}
}
async function onOpenSavedPage(run: WikiTransformationRun) {
if (!run.outputPageId || !store.currentKB) return
try {
// Resolve slug from the run's outputPageId by checking the local store
// first; if not loaded yet, fetch pages.
if (store.pages.length === 0) {
await store.fetchPages(store.currentKB.id)
}
const page = store.pages.find((p) => p.id === run.outputPageId)
if (page) {
await store.loadPage(store.currentKB.id, page.slug)
}
} catch (e: any) {
mcToast.error(e?.message ?? String(e))
}
}
watch(() => store.currentKB?.id, async (newId, oldId) => {
if (newId === oldId) return
templates.value = []
runsByTemplate.value = {}
selectedRawByTemplate.value = {}
if (store.currentKB) {
// Raw materials are loaded by the parent workspace, but if this tab is
// opened before the raw panel mounts, force a refresh so the picker has
// something to show.
if (store.rawMaterials.length === 0) {
try { await store.fetchRawMaterials(store.currentKB.id) } catch {}
}
await loadAll()
}
})
onMounted(async () => {
if (store.currentKB && store.rawMaterials.length === 0) {
try { await store.fetchRawMaterials(store.currentKB.id) } catch {}
}
await loadAll()
})
</script>
<style scoped>
.transformations-panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.header-text { flex: 1; min-width: 240px; }
.header-actions { display: flex; gap: 8px; }
.panel-title { font-size: 18px; font-weight: 700; margin: 0 0 4px; color: var(--mc-text-primary); }
.panel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.5; }
.btn-primary, .btn-secondary {
border-radius: 8px;
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border-color: var(--mc-border); }
.btn-secondary:hover:not(:disabled) { background: var(--mc-bg-sunken); }
.btn-danger:hover { color: var(--el-color-danger); border-color: var(--el-color-danger-light-5); }
.state-row {
display: flex;
align-items: center;
gap: 10px;
padding: 24px;
background: var(--mc-bg-muted);
border-radius: 10px;
color: var(--mc-text-secondary);
font-size: 14px;
}
.state-row--error { color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
.state-row--empty { justify-content: center; }
.templates-list { display: flex; flex-direction: column; gap: 12px; }
.template-card {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.template-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.template-title { display: flex; flex-direction: column; gap: 2px; }
.template-name { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); }
.template-handle { font-size: 12px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); }
.template-flags { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-start; }
.flag {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
border: 1px solid var(--mc-border-light);
white-space: nowrap;
}
.flag--on { color: var(--mc-primary); border-color: var(--mc-primary); }
.flag--muted { color: var(--mc-text-tertiary); }
.flag--scope { background: transparent; }
.template-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.5; }
.template-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.raw-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-size: 13px;
min-width: 220px;
}
.runs-list { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px dashed var(--mc-border-light); }
.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;
gap: 8px;
align-items: center;
font-size: 12px;
color: var(--mc-text-secondary);
padding: 4px 0;
}
.run-status {
font-size: 10px;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.04em;
}
.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-tokens { color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 11px; }
.run-output {
margin-top: 6px;
padding: 10px 12px;
background: var(--mc-bg-muted);
border-radius: 8px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace);
max-height: 360px;
overflow-y: auto;
}
.run-output--muted { color: var(--mc-text-tertiary); font-style: italic; }
.run-actions { display: flex; gap: 8px; padding: 8px 0 6px; }
.run-saved-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 4px;
background: var(--mc-primary);
color: white;
margin-left: auto;
white-space: nowrap;
}
.run-error {
margin-top: 6px;
padding: 10px 12px;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
border-radius: 8px;
font-size: 12px;
white-space: pre-wrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal {
width: min(640px, 90vw);
background: var(--mc-bg-elevated);
border-radius: 14px;
display: flex;
flex-direction: column;
max-height: 90vh;
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;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-head h3 { margin: 0; font-size: 15px; }
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--mc-text-secondary);
line-height: 1;
}
.modal-body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--mc-border-light);
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field-label { font-size: 12px; color: var(--mc-text-secondary); font-weight: 500; }
.field-input, .field-textarea {
border: 1px solid var(--mc-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.field-textarea { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); resize: vertical; }
.field-hint { font-size: 11px; color: var(--mc-text-tertiary); }
.field-row { display: flex; gap: 16px; }
.check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
.field--group {
border: 1px solid var(--mc-border-light);
border-radius: 8px;
padding: 10px 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.field--group legend { padding: 0 4px; font-size: 12px; color: var(--mc-text-secondary); font-weight: 500; }
.radio-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
</style>