Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ preserving each source budget. Retests copy each selected result once, preservin
its sample index and count, using the original fixed prompt; they never multiply
by the task repeat count again. Parameter copying merges repeated model rows
and restores the saved repeat count. New submissions retain UUID replay protection;
history, browsing, reconnecting and comparison are read-only.
history, browsing, reconnecting and comparison are read-only. Background task
snapshots update gallery metadata quietly. Mounted previews retain their document
for the same result when read capabilities rotate, preserving animation playback;
switching results or reopening a preview uses the current capability.
New manual tasks select Pelican and/or candy (the UI defaults to both). Every
generation, including repeats and retests, sends the original prompt verbatim:
`创建一个HTML,内容是SVG绘制一个鹈鹕骑自行车的2D动画,你不需要任何测试`.
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/components/admin/codex/PelicanPreview.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<div ref="container" class="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-white" data-testid="pelican-preview">
<img v-if="safeUrl.endsWith('/scene.svg')" :src="safeUrl" :alt="title" class="absolute inset-0 h-full w-full object-contain" loading="lazy" referrerpolicy="no-referrer" />
<img v-if="displayUrl.endsWith('/scene.svg')" :src="displayUrl" :alt="title" class="absolute inset-0 h-full w-full object-contain" loading="lazy" referrerpolicy="no-referrer" />
<iframe
v-else-if="safeUrl"
:src="safeUrl"
v-else-if="displayUrl"
:src="displayUrl"
:title="title"
class="absolute block border-0"
:style="frameStyle"
Expand All @@ -16,10 +16,10 @@
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { buildApiUrl } from '@/api/client'

const props = withDefaults(defineProps<{ previewUrl: string; title: string; interactive?: boolean }>(), { interactive: false })
const props = withDefaults(defineProps<{ previewUrl: string; title: string; resultId?: string; interactive?: boolean }>(), { interactive: false })
const container = ref<HTMLElement | null>(null)
const available = ref({ width: 0, height: 0 })
let observer: ResizeObserver | undefined
Expand All @@ -41,6 +41,13 @@ const safeUrl = computed(() => {
}
})

// Capabilities rotate on summary reads, but each result's saved work is immutable.
// Keep a mounted animation playing; a newly mounted preview uses the latest URL.
const displayUrl = ref('')
watch([safeUrl, () => props.resultId], ([url, id], [, previousId]) => {
if (!id || id !== previousId || !url || !displayUrl.value || url.endsWith('/scene.svg') !== displayUrl.value.endsWith('/scene.svg')) displayUrl.value = url
}, { immediate: true })

// Keep the authored viewport stable in thumbnails and enlarged previews. The
// containing 4:3 surface scales the whole document without changing its layout.
const frameStyle = computed(() => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/admin/codex/PelicanResultCard.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<article class="min-w-0 overflow-hidden rounded-xl border border-line bg-white shadow-sm dark:bg-dark-900" :data-test="`pelican-result-${result.id}`">
<button type="button" class="relative block aspect-[3/2] w-full overflow-hidden bg-gray-50 text-left focus-visible:ring-2 focus-visible:ring-primary-500 dark:bg-dark-800" :disabled="expired" :aria-label="`${text('查看详情', 'View details')} ${title}`" @click="$emit('open', result)">
<PelicanPreview v-if="result.preview_url && !expired" :preview-url="result.preview_url" :title="title" />
<PelicanPreview v-if="result.preview_url && !expired" :preview-url="result.preview_url" :result-id="result.id" :title="title" />
<span v-else class="flex h-full flex-col items-center justify-center gap-2 px-5 text-center text-sm text-muted"><span class="text-2xl" aria-hidden="true">{{ unfinished ? '◌' : result.evaluation?.test_items.includes('pelican') === false ? '21' : '↯' }}</span>{{ placeholder }}</span>
<span class="absolute bottom-3 right-3 rounded-md border border-white/70 bg-white/95 px-2.5 py-1.5 text-xs text-gray-700 shadow-sm dark:border-line dark:bg-dark-900/95 dark:text-gray-100">{{ text('查看详情 ↗', 'View details ↗') }}</span>
</button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/admin/codex/PelicanWorkDetail.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="min-w-0 space-y-4 text-sm" data-test="pelican-detail-content">
<div v-if="result.preview_url" class="aspect-[3/2] max-h-[60vh] w-full overflow-hidden rounded-lg border border-line"><PelicanPreview :preview-url="result.preview_url" :title="title" interactive /></div>
<div v-if="result.preview_url" class="aspect-[3/2] max-h-[60vh] w-full overflow-hidden rounded-lg border border-line"><PelicanPreview :preview-url="result.preview_url" :result-id="result.id" :title="title" interactive /></div>
<PelicanEvaluationBadges :evaluation="result.evaluation" :status="result.status" />
<dl class="grid gap-3 sm:grid-cols-2">
<div data-test="pelican-repeat-detail"><dt class="text-xs text-muted">{{ text('测试次数', 'Test run') }}</dt><dd>{{ repeatLabel }}</dd></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,34 @@ describe('Pelican isolated preview', () => {
wrapper.unmount()
})
})


it('keeps HTML and SVG animations mounted when the same result gets a new capability', async () => {
for (const asset of ['index.html', 'scene.svg']) {
const original = previewUrl.replace('index.html', asset)
const renewed = original.replace('a'.repeat(43), 'b'.repeat(43))
const wrapper = mount(PelicanPreview, { props: { previewUrl: original, resultId: 'work-1', title: 'Work' } })
const selector = asset === 'index.html' ? 'iframe' : 'img'
const animation = wrapper.get(selector).element
await wrapper.setProps({ previewUrl: renewed })
expect(wrapper.get(selector).element).toBe(animation)
expect(wrapper.get(selector).attributes('src')).toBe(new URL(original, window.location.origin).href)
await wrapper.setProps({ resultId: 'work-2' })
expect(wrapper.get(selector).attributes('src')).toBe(new URL(renewed, window.location.origin).href)
wrapper.unmount()
const reopened = mount(PelicanPreview, { props: { previewUrl: renewed, resultId: 'work-1', title: 'Work' } })
expect(reopened.get(selector).attributes('src')).toBe(new URL(renewed, window.location.origin).href)
reopened.unmount()
}
})

it('removes a mounted animation when its capability is withdrawn or becomes unsafe', async () => {
const wrapper = mount(PelicanPreview, { props: { previewUrl, resultId: 'work-1', title: 'Work' } })
await wrapper.setProps({ previewUrl: 'https://other.example/work.html' })
expect(wrapper.find('iframe').exists()).toBe(false)
await wrapper.setProps({ previewUrl })
expect(wrapper.find('iframe').exists()).toBe(true)
await wrapper.setProps({ previewUrl: '' })
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
6 changes: 3 additions & 3 deletions frontend/src/views/admin/CodexPelicanComparisonView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ let selection = 0, content = 0
function apiStatus(value: unknown) { return value && typeof value === 'object' && 'status' in value ? Number(value.status) : 0 }
function report(value: unknown) { if (!lifecycle.signal.aborted) error.value = extractApiErrorMessage(value, text('操作失败,请查看原因。', 'Operation failed; review the error.')) }
async function loadHistory() { try { history.value = await pelicanTestsAPI.listTasks(historyPage.value, lifecycle.signal) } catch (value) { report(value) } }
async function loadGallery() {
galleryController?.abort(); const controller = new AbortController(); galleryController = controller; loading.value = true
async function loadGallery(background = false) {
galleryController?.abort(); const controller = new AbortController(); galleryController = controller; loading.value = !background
try {
const value = await pelicanTestsAPI.gallery({ page: page.value, page_size: 24, task_id: taskId.value || undefined, account_id: Number(filters.account) || undefined, model: filters.model.trim() || undefined, effort: filters.effort || undefined, scene: filters.scene || undefined, verdict: filters.verdict || undefined, status: filters.status || undefined }, controller.signal)
if (!controller.signal.aborted && !lifecycle.signal.aborted) results.value = value
Expand All @@ -104,7 +104,7 @@ async function observe(id: string) {
let delay = 1000
while (!controller.signal.aborted && taskId.value === id) {
try {
await pelicanTestsAPI.observeTask(id, event => { if (controller.signal.aborted) return; activeTask.value = event.task; connection.value = 'connected'; delay = 1000; void loadGallery(); if (event.type === 'task_complete') { stopping.value = false; void loadHistory() } }, controller.signal)
await pelicanTestsAPI.observeTask(id, event => { if (controller.signal.aborted) return; activeTask.value = event.task; connection.value = 'connected'; delay = 1000; void loadGallery(true); if (event.type === 'task_complete') { stopping.value = false; void loadHistory() } }, controller.signal)
return
} catch {
if (controller.signal.aborted) return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import CodexPelicanComparisonView from '../CodexPelicanComparisonView.vue'
import type { PelicanResultSummary, PelicanTaskSnapshot } from '@/api/admin/pelicanTests'
import type { PelicanResultPage, PelicanResultSummary, PelicanTaskEvent, PelicanTaskSnapshot } from '@/api/admin/pelicanTests'

const mocks = vi.hoisted(() => ({ gallery: vi.fn(), getOptions: vi.fn(), listTasks: vi.fn(), getTask: vi.fn(), getResults: vi.fn(), getResult: vi.fn(), startTask: vi.fn(), retestTask: vi.fn(), cancelTask: vi.fn(), deleteTask: vi.fn(), deleteResult: vi.fn(), observeTask: vi.fn(), locale: { value: 'zh' } }))
vi.mock('@/api/admin/pelicanTests', async original => ({ ...(await original<typeof import('@/api/admin/pelicanTests')>()), pelicanTestsAPI: mocks }))
Expand All @@ -22,7 +22,7 @@ beforeEach(() => {
mocks.deleteTask.mockResolvedValue({ deleted: true }); mocks.deleteResult.mockResolvedValue({ deleted: true })
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.unstubAllGlobals() })
async function make() { const wrapper = mount(CodexPelicanComparisonView, { global: { stubs: { AppLayout: { template: '<div><slot /></div>' }, BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' }, PelicanTaskComposer: { props: ['initial', 'submitting'], emits: ['submit'], template: '<div data-test="composer-stub" />' }, PelicanPreview: true } } }); wrappers.push(wrapper); await flushPromises(); return wrapper }
async function make(realPreview = false) { const wrapper = mount(CodexPelicanComparisonView, { global: { stubs: { AppLayout: { template: '<div><slot /></div>' }, BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' }, PelicanTaskComposer: { props: ['initial', 'submitting'], emits: ['submit'], template: '<div data-test="composer-stub" />' }, PelicanPreview: !realPreview } } }); wrappers.push(wrapper); await flushPromises(); return wrapper }
async function selectTask(wrapper: ReturnType<typeof mount>) { await wrapper.get('[data-test="pelican-task-select"]').setValue('task-1'); await flushPromises() }

describe('Pelican gallery workbench', () => {
Expand Down Expand Up @@ -125,3 +125,32 @@ it('keeps original-mode HTML works previewable without empty scene or nonce labe
expect(content.text()).not.toContain('场景:'); expect(content.text()).not.toContain('校验码:')
expect(content.text()).toContain('作品格式'); expect(content.text()).toContain('Review error')
})


it('updates task progress quietly without restarting the displayed HTML animation', async () => {
let onEvent!: (event: PelicanTaskEvent) => void
mocks.observeTask.mockImplementation((_id: string, callback: typeof onEvent, signal: AbortSignal) => {
onEvent = callback
return new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
})
mocks.getTask.mockResolvedValue(task('task-1', 'running'))
const value = result()
value.preview_url = value.preview_url!.replace('scene.svg', 'index.html')
mocks.gallery.mockResolvedValue({ items: [value], total: 1, page: 1, page_size: 24 })
const wrapper = await make(true); await selectTask(wrapper)
const animation = wrapper.get('iframe').element
const source = wrapper.get('iframe').attributes('src')
let completeRead!: (page: PelicanResultPage) => void
mocks.gallery.mockImplementationOnce(() => new Promise(resolve => { completeRead = resolve }))
const progress = { ...task('task-1', 'running'), completed: 2 }
onEvent({ type: 'snapshot', task: progress }); await wrapper.vm.$nextTick()
expect(wrapper.get('[data-test="pelican-active-task"]').text()).toContain('已处理 2 / 3')
expect(wrapper.text()).not.toContain('读取中…')
expect((wrapper.get('[data-test="pelican-refresh"]').element as HTMLButtonElement).disabled).toBe(false)
completeRead({ items: [{ ...value, preview_url: value.preview_url!.replace('a'.repeat(43), 'b'.repeat(43)), evaluation: { ...value.evaluation!, review: 'passed', verdict: 'passed' } }], total: 1, page: 1, page_size: 24 })
await flushPromises()
expect(wrapper.get('iframe').element).toBe(animation)
expect(wrapper.get('iframe').attributes('src')).toBe(source)
expect(wrapper.get('[data-test="pelican-result-r1"]').text()).toContain('视觉审核 · 通过')
expect(mocks.startTask).not.toHaveBeenCalled(); expect(mocks.retestTask).not.toHaveBeenCalled(); expect(mocks.cancelTask).not.toHaveBeenCalled()
})
Loading