Expand generation harness observability

This commit is contained in:
2026-06-24 10:48:23 +08:00
parent 459ca9edef
commit 1f34d80083
35 changed files with 8003 additions and 112 deletions

View File

@@ -47,6 +47,21 @@ interface GenerationProviderStats {
estimated_cost_usd: number
}
interface GenerationTraceBucket {
name: string
count: number
}
interface GenerationTraceSummary {
story_id: number
window_days: number | null
total_events: number
failed_events: number
by_step: GenerationTraceBucket[]
by_artifact: GenerationTraceBucket[]
failure_categories: GenerationTraceBucket[]
}
const props = withDefaults(
defineProps<{
storyId: number | null
@@ -64,6 +79,7 @@ const props = withDefaults(
const jobs = ref<GenerationJobSummary[]>([])
const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null)
const traceSummary = ref<GenerationTraceSummary | null>(null)
const loading = ref(false)
const actionLoading = ref(false)
const error = ref('')
@@ -79,6 +95,8 @@ const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
})
const topTraceStep = computed(() => traceSummary.value?.by_step[0] ?? null)
const topFailureCategory = computed(() => traceSummary.value?.failure_categories[0] ?? null)
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'
@@ -117,15 +135,18 @@ function statusLabel(status?: string) {
function eventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
workflow_planned: '工作流规划',
worker_started: '后台任务开始',
retry_queued: '重新排队',
cancel_requested: '已请求取消',
context_prepared: '上下文准备',
evaluation_completed: '内容评测',
narrative_generated: '正文生成',
story_saved: '故事保存',
provider_call_started: '供应商调用',
provider_call_succeeded: '供应商成功',
provider_call_failed: '供应商失败',
quality_gate_failed: '质量门失败',
cover_image_started: '封面开始',
cover_image_succeeded: '封面就绪',
cover_image_failed: '封面失败',
@@ -147,6 +168,73 @@ function eventLabel(eventType: string) {
return labels[eventType] ?? eventType
}
function stepLabel(step?: unknown) {
const labels: Record<string, string> = {
request_acceptance: '请求接收',
worker_start: '后台启动',
context_preparation: '上下文准备',
narrative_generation: '主内容生成',
evaluation: '内容评测',
story_persistence: '故事保存',
provider_invocation: '供应商调用',
image_generation: '图片生成',
audio_generation: '音频生成',
asset_retry: '资源重试',
asset_generation: '资源生成',
postprocessing: '后处理',
completion: '任务完成',
cancellation: '取消',
stale_recovery: '超时收敛',
unknown: '未知步骤',
}
const key = typeof step === 'string' ? step : ''
return labels[key] ?? key
}
function artifactLabel(artifact?: unknown) {
const labels: Record<string, string> = {
story_text: '故事正文',
storybook_pages: '绘本分页',
cover_image: '封面图',
page_image: '分页插图',
image: '图片资源',
audio: '音频',
achievement_memory: '成长记忆',
none: '无资源',
unknown: '未知资源',
}
const key = typeof artifact === 'string' ? artifact : ''
return labels[key] ?? key
}
function failureCategoryLabel(category?: unknown) {
const labels: Record<string, string> = {
provider_error: '供应商失败',
schema_error: '结构不完整',
safety_error: '儿童安全风险',
timeout: '超时',
canceled: '用户取消',
stale_job: '任务卡住',
storage_error: '存储失败',
validation_error: '输入校验失败',
unknown_error: '未知失败',
}
const key = typeof category === 'string' ? category : ''
return labels[key] ?? key
}
function traceMetaText(event: GenerationJobEvent) {
const meta = event.event_metadata
const step = stepLabel(meta.step)
const artifact = artifactLabel(meta.artifact)
const failureCategory = meta.failure_category
? failureCategoryLabel(meta.failure_category)
: ''
return [step, artifact && artifact !== '无资源' ? artifact : '', failureCategory]
.filter(Boolean)
.join(' · ')
}
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
@@ -188,22 +276,25 @@ async function selectJob(jobId: string) {
async function refresh() {
if (props.storyId === null) {
jobs.value = []
activeJob.value = null
providerStats.value = null
return
jobs.value = []
activeJob.value = null
providerStats.value = null
traceSummary.value = null
return
}
error.value = ''
const selectedJobId = activeJob.value?.id ?? null
try {
const [nextJobs, stats] = await Promise.all([
const [nextJobs, stats, trace] = await Promise.all([
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
api.get<GenerationTraceSummary>(`/api/generations/${props.storyId}/trace-summary`),
])
jobs.value = nextJobs
providerStats.value = stats
traceSummary.value = trace
const nextJobId = (
selectedJobId
? jobs.value.find((job) => job.id === selectedJobId)?.id
@@ -218,6 +309,7 @@ async function refresh() {
jobs.value = []
activeJob.value = null
providerStats.value = null
traceSummary.value = null
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
}
}
@@ -331,6 +423,32 @@ defineExpose({ refresh })
</div>
</div>
<div
v-if="traceSummary?.total_events"
class="grid gap-3 md:grid-cols-4"
>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">流程事件</div>
<div class="mt-1 text-xl font-semibold">{{ traceSummary.total_events }}</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">{{ traceSummary.failed_events }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">主要步骤</div>
<div class="mt-1 text-base font-semibold">
{{ topTraceStep ? `${stepLabel(topTraceStep.name)} · ${topTraceStep.count}` : '暂无' }}
</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">主要失败</div>
<div class="mt-1 text-base font-semibold">
{{ topFailureCategory ? `${failureCategoryLabel(topFailureCategory.name)} · ${topFailureCategory.count}` : '暂无' }}
</div>
</div>
</div>
<div v-if="!jobs.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedClass">
暂无生成轨迹旧数据会在下一次资源补全后开始记录
</div>
@@ -445,6 +563,9 @@ defineExpose({ refresh })
<p v-else-if="event.message" class="mt-1 text-xs text-gray-500">
{{ event.message }}
</p>
<p v-if="traceMetaText(event)" class="mt-1 text-xs text-gray-500">
{{ traceMetaText(event) }}
</p>
</div>
</li>
</ol>