diff --git a/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts b/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts index faf117f..f4054f1 100644 --- a/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts +++ b/app/api/admin/ai-models/providers/[id]/verifications/[verificationId]/refresh/route.ts @@ -1,7 +1,8 @@ import { aiModelAdminRoute } from '@/lib/ai-model-access' import { refreshAiModelVerification } from '@/lib/ai-model-verification' +import { parseDetailJson } from '@/lib/detail-generator/validation' export const runtime = 'nodejs' export const maxDuration = 300 -export async function POST(_request: Request, context: { params: { id: string; verificationId: string } }) { - return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId)) +export async function POST(request: Request, context: { params: { id: string; verificationId: string } }) { + return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId, request.body ? await parseDetailJson(request) : {})) } diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx index b3a6d53..fa33e24 100644 --- a/components/admin/AiModelManagementPanel.test.tsx +++ b/components/admin/AiModelManagementPanel.test.tsx @@ -19,6 +19,34 @@ const configured = (): AiModelAdminState => ({ afterEach(() => jest.restoreAllMocks()) beforeEach(() => sessionStorage.clear()) +test.each(['untested', 'passed'] as const)('参考图数量以实测证据为准,%s 不把平台测试上限当成已支持', async status => { + const next = configured(), model = next.providers[0].models[1] + model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } } + model.verification.image = { ...model.verification.image, status, ...(status === 'passed' ? { verifiedReferenceImageCount: 4 } : {}) } + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' }) + fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' })) + expect(within(row).getByText(status === 'passed' ? '参考图:已逐张验证 4 张,本平台最多 4 张' : '参考图:数量待测试(本平台最多测试 8 张)')).toBeVisible() +}) + +test('数量选择、费用确认和实际测试请求使用同一个参考图数量', async () => { + const next = configured(), model = next.providers[0].models[1] + model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } } + model.verification.image.verifiedReferenceImageCount = 4 + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' }) + fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' })) + fireEvent.change(within(row).getByRole('combobox', { name: 'nano-banana-2 · 现有上游 本次测试参考图数量' }), { target: { value: '2' } }) + fireEvent.click(within(row).getByRole('button', { name: '测试 nano-banana-2 · 现有上游 的图片生成' })) + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveTextContent('逐张校验 2 张参考图,输出一张拼图。') + fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' })) + await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)) + expect(JSON.parse((global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))[1].body)).toMatchObject({ stage: 'image', referenceImageCount: 2 }) +}) + function jointConfiguration() { const next = configured() next.providers[0].models[2].capabilities.combinedTextTest = true diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx index 9943ffd..1b89a27 100644 --- a/components/admin/AiModelManagementPanel.tsx +++ b/components/admin/AiModelManagementPanel.tsx @@ -32,7 +32,7 @@ function needsReferenceAdaptation(model: AiModelCatalogEntry) { } interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string } -interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean } +interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchImage: boolean; requestId: string } const pendingStorageKey = 'ai-model-pending-submissions-v1' class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } } @@ -63,6 +63,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) const [testStages, setTestStages] = useState>({}) + const [testReferenceCounts, setTestReferenceCounts] = useState>({}) const [expandedDetails, setExpandedDetails] = useState>({}) const busyRef = useRef(false) const uncertainAttempts = useRef(new Map()) @@ -145,7 +146,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang accept(next, options.preserveDraft) setNotice(success) if (identity.startsWith('submission:')) setNotice(next.submissionCheck?.received ? '已找到原测试记录' : '原提交未被受理,已关闭,可以重新测试') - if (identity === 'reconcile') setRecovery(null) + if (identity === 'reconcile' || identity === 'recover-result') setRecovery(null) if (options.closeForm) setForm(null) } catch (cause) { const message = cause instanceof Error ? cause.message : '操作未完成,请重试' @@ -188,14 +189,15 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang setBusy(`confirm:${model.key}`) setTestStages((current) => ({ ...current, [model.key]: stage })) const label = purposeLabel(stage), attemptedStages = aiModelTestStages(stage) - const confirmation = await confirmAction({ title: `测试${label}`, description: <>{model.id}
{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。
}将实际调用模型,可能产生上游费用。, confirmLabel: '开始测试', tone: 'primary' }) + const referenceImageCount = stage === 'image' && model.capabilities.protocols?.image?.referenceLimitSource === 'verification' ? testReferenceCounts[model.key] ?? model.verification.image.verifiedReferenceImageCount ?? model.capabilities.protocols.image.maxImages : undefined + const confirmation = await confirmAction({ title: `测试${label}`, description: <>{model.id}
{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。
}{referenceImageCount && <>逐张校验 {referenceImageCount} 张参考图,输出一张拼图。
}将实际调用模型,可能产生上游费用。, confirmLabel: '开始测试', tone: 'primary' }) if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return } setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('') let idempotencyKey = '' try { idempotencyKey = createBrowserUuid() uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey }); rememberAttempts() - const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey }) }) + const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey, ...(referenceImageCount ? { referenceImageCount } : {}) }) }) uncertainAttempts.current.delete(model.key); rememberAttempts() accept(next, true) } catch (cause) { @@ -256,6 +258,10 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true }) }}>

核查原请求 · {recovery.modelId}

+ {recovery.canFetchImage &&
+

有客户请求编号时,可取回原图并校验,不会重新生成。

+
+
}

先在上游核查并确认请求已结束。此操作不会通过接口测试。