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

386 lines
13 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 LoadingSpinner from './ui/LoadingSpinner.vue'
interface GenerationJobSummary {
id: string
story_id: number | null
output_mode: string
input_type: string
status: string
current_step: string
progress_percent: number
progress_label: string
is_terminal: boolean
result_snapshot: Record<string, unknown>
error_message: string | null
created_at: string
updated_at: string
}
interface GenerationJobEvent {
id: number
job_id: string
story_id: number | null
event_type: string
status: string
message: string | null
event_metadata: Record<string, unknown>
created_at: string
}
interface GenerationJobDetail extends GenerationJobSummary {
request_payload: Record<string, unknown>
events: GenerationJobEvent[]
}
interface GenerationProviderStats {
story_id: number
total_calls: number
successful_calls: number
failed_calls: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
const props = withDefaults(
defineProps<{
storyId: number | null
tone?: 'light' | 'dark'
title?: string
description?: string
}>(),
{
tone: 'light',
title: '生成轨迹',
description: '查看生成、资源补全和 Provider 调用事件,便于演示时解释状态来源与失败恢复。',
},
)
const jobs = ref<GenerationJobSummary[]>([])
const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false)
const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobs.value[0] ?? null)
const activeEvents = 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 mutedClass = computed(() => (isDark.value ? 'text-white/65' : 'text-gray-500'))
const shellClass = computed(() => (
isDark.value ? 'border-white/10 bg-white/10 text-white backdrop-blur' : 'border-gray-100 bg-white/85 text-gray-900'
))
const panelClass = computed(() => (
isDark.value ? 'border-white/10 bg-black/25' : 'border-gray-100 bg-gray-50/80'
))
const statusClassMap: 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 statusClass(status?: string) {
return statusClassMap[status ?? ''] ?? 'border-gray-200 bg-gray-50 text-gray-600'
}
function statusLabel(status?: string) {
const labels: Record<string, string> = {
running: '进行中',
queued: '已排队',
succeeded: '成功',
completed: '已完成',
degraded_completed: '降级完成',
failed: '失败',
}
return labels[status ?? ''] ?? '未知'
}
function eventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
worker_started: '后台任务开始',
context_prepared: '上下文准备',
narrative_generated: '正文生成',
story_saved: '故事保存',
provider_call_started: 'Provider 调用',
provider_call_succeeded: 'Provider 成功',
provider_call_failed: 'Provider 失败',
cover_image_started: '封面开始',
cover_image_succeeded: '封面就绪',
cover_image_failed: '封面失败',
storybook_images_started: '绘本插图开始',
storybook_images_completed: '绘本插图完成',
storybook_page_image_succeeded: '分页插图就绪',
storybook_page_image_failed: '分页插图失败',
audio_cache_hit: '音频缓存命中',
audio_started: '音频开始',
audio_succeeded: '音频就绪',
audio_failed: '音频失败',
asset_retry_started: '资源重试开始',
asset_retry_completed: '资源重试完成',
asset_retry_failed: '资源重试失败',
generation_completed: '生成完成',
generation_failed: '生成失败',
}
return labels[eventType] ?? eventType
}
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
}
function providerText(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 selectJob(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) {
jobs.value = []
activeJob.value = null
providerStats.value = null
return
}
error.value = ''
try {
const [nextJobs, stats] = await Promise.all([
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
])
jobs.value = nextJobs
providerStats.value = stats
const nextJobId = jobs.value[0]?.id
if (nextJobId) {
await selectJob(nextJobId)
} else {
activeJob.value = null
}
} catch (e) {
jobs.value = []
activeJob.value = null
providerStats.value = null
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
}
}
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="shellClass">
<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="mutedClass">
{{ props.description }}
</p>
</div>
<span
v-if="latestJob"
class="w-fit rounded-full border px-3 py-1.5 text-sm font-medium"
:class="statusClass(latestJob.status)"
>
最近任务{{ statusLabel(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="mutedClass">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="mutedClass">平均耗时</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="mutedClass">预估成本</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="mutedClass">调用次数</div>
<div class="mt-1 text-xl font-semibold">{{ providerStats.total_calls }}</div>
</div>
</div>
<div v-if="!jobs.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedClass">
暂无生成轨迹旧数据会在下一次资源补全后开始记录
</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 jobs"
:key="job.id"
type="button"
class="min-w-[190px] rounded-lg border px-3 py-3 text-left transition lg:w-full"
:class="activeJob?.id === job.id ? 'border-gray-900 bg-gray-50 text-gray-900' : 'border-gray-100 bg-white text-gray-900 hover:bg-gray-50'"
@click="selectJob(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="statusClass(job.status)">
{{ statusLabel(job.status) }}
</span>
</div>
<div class="mt-2 flex items-center gap-1 text-xs text-gray-500">
<ClockIcon class="h-4 w-4" />
{{ formatTime(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="mutedClass">
当前步骤{{ eventLabel(activeJob.current_step) }}
</div>
</div>
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="statusClass(activeJob.status)">
{{ statusLabel(activeJob.status) }}
</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-xs" :class="mutedClass">
<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 activeEvents"
:key="event.id"
class="grid grid-cols-[88px_minmax(0,1fr)] gap-3"
>
<div class="pt-1 text-xs" :class="mutedClass">
{{ formatTime(event.created_at) }}
</div>
<div class="rounded-lg border border-white bg-white px-3 py-2 text-gray-900">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">{{ eventLabel(event.event_type) }}</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="statusClass(event.status)">
{{ statusLabel(event.status) }}
</span>
</div>
<p v-if="event.event_type.startsWith('provider_call')" class="mt-1 text-xs text-gray-500">
{{ providerText(event) }}
</p>
<p v-else-if="event.message" class="mt-1 text-xs text-gray-500">
{{ event.message }}
</p>
</div>
</li>
</ol>
</div>
</div>
</div>
</div>
</section>
</template>