Files
dreamweaver/frontend/src/components/GenerationTrace.vue

431 lines
15 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.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
import { api } from '../api/client'
import type {
GenerationJobDetail,
GenerationJobEvent,
GenerationJobSummary,
GenerationProviderStats,
} from '../types/generation'
import LoadingSpinner from './ui/LoadingSpinner.vue'
const props = withDefaults(
defineProps<{
storyId: number | null
tone?: 'light' | 'dark'
title?: string
description?: string
}>(),
{
tone: 'light',
title: '生成轨迹',
description: '每次生成和资源重试都会留下事件流便于确认当前结果来自哪次任务、Provider 是否成功、失败是否可恢复。',
},
)
const jobHistory = ref<GenerationJobSummary[]>([])
const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false)
const actionLoading = ref(false)
const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobHistory.value[0] ?? null)
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
const shouldAutoRefresh = computed(() => {
if (activeJob.value) return !activeJob.value.is_terminal
if (latestJob.value) return !latestJob.value.is_terminal
return false
})
const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
})
const containerClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10 text-white shadow-2xl backdrop-blur'
: 'border-gray-100 bg-white/85 text-gray-900'
))
const mutedTextClass = computed(() => (isDark.value ? 'text-white/65' : 'text-gray-500'))
const panelClass = computed(() => (
isDark.value
? 'border-white/10 bg-black/25'
: 'border-gray-100 bg-gray-50/80'
))
const itemClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10 hover:bg-white/15'
: 'border-gray-100 bg-white hover:border-gray-300 hover:bg-gray-50'
))
const activeItemClass = computed(() => (
isDark.value
? 'border-white/60 bg-white/20'
: 'border-gray-900 bg-gray-50'
))
const eventCardClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10'
: 'border-white bg-white'
))
const jobStatusClassMap: Record<string, string> = {
running: 'border-amber-200 bg-amber-50 text-amber-700',
queued: 'border-sky-200 bg-sky-50 text-sky-700',
succeeded: 'border-emerald-200 bg-emerald-50 text-emerald-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
degraded_completed: 'border-orange-200 bg-orange-50 text-orange-700',
canceled: 'border-slate-200 bg-slate-100 text-slate-700',
failed: 'border-rose-200 bg-rose-50 text-rose-700',
}
function getJobStatusClass(status?: string) {
return jobStatusClassMap[status ?? ''] ?? 'border-gray-200 bg-gray-50 text-gray-600'
}
function getJobStatusLabel(status?: string) {
const labels: Record<string, string> = {
running: '进行中',
queued: '已排队',
succeeded: '成功',
completed: '已完成',
degraded_completed: '降级完成',
canceled: '已取消',
failed: '失败',
}
return labels[status ?? ''] ?? '未知'
}
function getEventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
worker_started: '后台任务开始',
retry_queued: '重新排队',
cancel_requested: '已请求取消',
context_prepared: '上下文准备',
narrative_generated: '正文生成',
story_saved: '故事保存',
cover_image_started: '封面开始',
cover_image_succeeded: '封面就绪',
cover_image_failed: '封面失败',
storybook_images_started: '绘本插图开始',
storybook_images_completed: '绘本插图完成',
storybook_cover_image_succeeded: '绘本封面就绪',
storybook_cover_image_failed: '绘本封面失败',
storybook_page_image_succeeded: '分页插图就绪',
storybook_page_image_failed: '分页插图失败',
audio_cache_hit: '音频缓存命中',
audio_started: '音频开始',
audio_succeeded: '音频就绪',
audio_failed: '音频失败',
provider_call_started: 'Provider 调用',
provider_call_succeeded: 'Provider 成功',
provider_call_failed: 'Provider 失败',
asset_retry_started: '资源重试开始',
asset_retry_completed: '资源重试完成',
asset_retry_failed: '资源重试失败',
generation_canceled: '任务已取消',
generation_completed: '生成完成',
generation_failed: '生成失败',
asset_generation_completed: '资源生成完成',
asset_generation_failed: '资源生成失败',
}
return labels[eventType] ?? eventType
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
}
function getProviderEventText(event: GenerationJobEvent) {
const meta = event.event_metadata
const adapter = meta.adapter ? String(meta.adapter) : ''
const latency = typeof meta.latency_ms === 'number' ? `${meta.latency_ms}ms` : ''
const cost = typeof meta.estimated_cost_usd === 'number'
? `$${meta.estimated_cost_usd.toFixed(4)}`
: ''
return [adapter, latency, cost].filter(Boolean).join(' · ')
}
function formatLatency(value?: number | null) {
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
}
function formatCost(value?: number | null) {
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
}
async function selectGenerationJob(jobId: string) {
loading.value = true
error.value = ''
try {
activeJob.value = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
} finally {
loading.value = false
}
}
async function refresh() {
if (props.storyId === null) {
jobHistory.value = []
activeJob.value = null
providerStats.value = null
return
}
error.value = ''
try {
const [jobs, stats] = await Promise.all([
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
])
jobHistory.value = jobs
providerStats.value = stats
const nextJobId = jobHistory.value[0]?.id
if (nextJobId) {
await selectGenerationJob(nextJobId)
} else {
activeJob.value = null
}
} catch (e) {
jobHistory.value = []
activeJob.value = null
providerStats.value = null
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
}
}
async function cancelActiveJob() {
if (!activeJob.value || actionLoading.value) return
actionLoading.value = true
error.value = ''
try {
await api.post(`/api/generations/jobs/${activeJob.value.id}/cancel`)
await refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '取消任务失败'
} finally {
actionLoading.value = false
}
}
async function retryActiveJob() {
if (!activeJob.value || actionLoading.value) return
actionLoading.value = true
error.value = ''
try {
await api.post(`/api/generations/jobs/${activeJob.value.id}/retry`)
await refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '重新排队失败'
} finally {
actionLoading.value = false
}
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
watch(
() => props.storyId,
() => {
void refresh()
},
{ immediate: true },
)
watch(shouldAutoRefresh, (enabled) => {
stopAutoRefresh()
if (enabled) {
refreshTimer = setInterval(() => {
if (!loading.value) {
void refresh()
}
}, 2500)
}
})
onBeforeUnmount(stopAutoRefresh)
defineExpose({ refresh })
</script>
<template>
<section class="rounded-lg border p-5" :class="containerClass">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="flex items-center gap-2 font-semibold">
<BoltIcon class="h-5 w-5 text-amber-500" />
{{ props.title }}
</div>
<p class="mt-2 text-sm leading-6" :class="mutedTextClass">
{{ props.description }}
</p>
</div>
<span
v-if="latestJob"
class="w-fit rounded-full border px-3 py-1.5 text-sm font-medium"
:class="getJobStatusClass(latestJob.status)"
>
最近任务{{ getJobStatusLabel(latestJob.status) }}
</span>
</div>
<div v-if="error" class="mt-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-sm text-rose-600">
{{ error }}
</div>
<div v-else class="mt-5 space-y-5">
<div
v-if="providerStats?.total_calls"
class="grid gap-3 md:grid-cols-4"
>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">Provider 成功率</div>
<div class="mt-1 text-xl font-semibold">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">平均耗时</div>
<div class="mt-1 text-xl font-semibold">{{ formatLatency(providerStats.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">预估成本</div>
<div class="mt-1 text-xl font-semibold">{{ formatCost(providerStats.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">调用次数</div>
<div class="mt-1 text-xl font-semibold">{{ providerStats.total_calls }}</div>
</div>
</div>
<div v-if="!jobHistory.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedTextClass">
暂无生成轨迹旧数据会在下一次资源补全后开始记录
</div>
<div v-else class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-2 lg:overflow-visible">
<button
v-for="job in jobHistory"
:key="job.id"
type="button"
class="min-w-[190px] rounded-lg border px-3 py-3 text-left transition lg:w-full"
:class="[itemClass, activeJob?.id === job.id ? activeItemClass : '']"
@click="selectGenerationJob(job.id)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold">
{{ job.output_mode === 'asset_retry' ? '资源重试' : '内容生成' }}
</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(job.status)">
{{ getJobStatusLabel(job.status) }}
</span>
</div>
<div class="mt-2 flex items-center gap-1 text-xs" :class="mutedTextClass">
<ClockIcon class="h-4 w-4" />
{{ formatDateTime(job.created_at) }}
</div>
</button>
</div>
<div class="rounded-lg border p-4" :class="panelClass">
<LoadingSpinner v-if="loading" size="sm" text="正在读取生成轨迹..." />
<div v-else-if="activeJob" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">
{{ activeJob.output_mode === 'asset_retry' ? '资源重试事件' : '生成事件' }}
</div>
<div class="mt-1 text-xs" :class="mutedTextClass">
当前步骤{{ getEventLabel(activeJob.current_step) }}
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<button
v-if="activeJob.can_cancel"
type="button"
class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="actionLoading"
@click="cancelActiveJob"
>
{{ actionLoading ? '处理中...' : '取消任务' }}
</button>
<button
v-if="activeJob.can_retry"
type="button"
class="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-medium text-sky-700 transition hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="actionLoading"
@click="retryActiveJob"
>
{{ actionLoading ? '处理中...' : '重新排队' }}
</button>
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="getJobStatusClass(activeJob.status)">
{{ getJobStatusLabel(activeJob.status) }}
</span>
</div>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-xs" :class="mutedTextClass">
<span>{{ activeProgressLabel }}</span>
<span>{{ activeProgress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200/80">
<div
class="h-full rounded-full bg-emerald-500 transition-all duration-300"
:style="{ width: `${activeProgress}%` }"
/>
</div>
</div>
<ol class="space-y-3">
<li
v-for="event in activeJobEvents"
:key="event.id"
class="grid grid-cols-[88px_minmax(0,1fr)] gap-3"
>
<div class="pt-1 text-xs" :class="mutedTextClass">
{{ formatDateTime(event.created_at) }}
</div>
<div class="rounded-lg border px-3 py-2" :class="eventCardClass">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">{{ getEventLabel(event.event_type) }}</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(event.status)">
{{ getJobStatusLabel(event.status) }}
</span>
</div>
<p v-if="event.event_type.startsWith('provider_call')" class="mt-1 text-xs" :class="mutedTextClass">
{{ getProviderEventText(event) }}
</p>
<p v-else-if="event.message" class="mt-1 text-xs" :class="mutedTextClass">
{{ event.message }}
</p>
</div>
</li>
</ol>
</div>
</div>
</div>
</div>
</section>
</template>