feat: add generation trace and partial-ready workflow status

This commit is contained in:
2026-04-18 21:53:55 +08:00
parent 96dfc677e2
commit e99a7fbe14
36 changed files with 2597 additions and 144 deletions

View File

@@ -0,0 +1,347 @@
<script setup lang="ts">
import { computed, 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 error = ref('')
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 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',
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: '降级完成',
failed: '失败',
}
return labels[status ?? ''] ?? '未知'
}
function getEventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
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_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 : '生成轨迹加载失败'
}
}
watch(
() => props.storyId,
() => {
void refresh()
},
{ immediate: true },
)
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>
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="getJobStatusClass(activeJob.status)">
{{ getJobStatusLabel(activeJob.status) }}
</span>
</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>