fix(ai): support model tests on HTTP browsers
This commit is contained in:
parent
21b6eb767c
commit
88ce9e3798
@ -24,6 +24,41 @@ function jointConfiguration() {
|
||||
next.providers[0].models[2].capabilities.combinedTextTest = true
|
||||
return next
|
||||
}
|
||||
test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测试', async () => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
|
||||
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined })
|
||||
try {
|
||||
const next = jointConfiguration()
|
||||
global.fetch = jest.fn(async () => response(next)) as typeof fetch
|
||||
render(<AiModelManagementPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' }))
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
|
||||
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
|
||||
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
|
||||
expect(JSON.parse(call[1].body)).toMatchObject({ stage: 'analysis-copy', idempotencyKey: expect.stringMatching(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
} finally { if (original) Object.defineProperty(globalThis.crypto, 'randomUUID', original); else delete (globalThis.crypto as any).randomUUID }
|
||||
})
|
||||
test('编号生成失败不会提交、刷新或伪造未知状态,恢复后可正常重试', async () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!, original = globalThis.crypto
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} })
|
||||
try {
|
||||
const next = jointConfiguration()
|
||||
global.fetch = jest.fn(async () => response(next)) as typeof fetch
|
||||
render(<AiModelManagementPanel />)
|
||||
const button = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('当前浏览器无法生成请求编号,请更新浏览器后再试。测试尚未提交。')
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('button', { name: /核查 new-vision-model/ })).not.toBeInTheDocument()
|
||||
expect(button).toBeEnabled()
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: original })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
|
||||
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
|
||||
} finally { Object.defineProperty(globalThis, 'crypto', descriptor) }
|
||||
})
|
||||
test('多模态模型一次确认、一次请求,通过后两项可选但不会自动开放', async () => {
|
||||
const next = jointConfiguration(), passed = jointConfiguration()
|
||||
for (const stage of ['analysis', 'copy'] as const) passed.providers[0].models[2].verification[stage] = { status: 'passed', id: `joint-${stage}`, checkedAt: null, requestId: 'one-upstream-request', message: '该用途已通过', purpose: 'analysis-copy' }
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react'
|
||||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { AI_MODEL_TEST_PURPOSES, aiModelTestStages, type AiModelAdminState, type AiModelCatalogEntry, type AiModelProvider, type AiModelStage, type AiModelTestPurpose } from '@/lib/ai-model-types'
|
||||
|
||||
const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [
|
||||
@ -183,7 +184,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
|
||||
setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('')
|
||||
let idempotencyKey = ''
|
||||
try {
|
||||
idempotencyKey = crypto.randomUUID()
|
||||
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 }) })
|
||||
uncertainAttempts.current.delete(model.key); rememberAttempts()
|
||||
@ -191,6 +192,10 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
|
||||
} catch (cause) {
|
||||
// Refresh state after a lost response; never repeat a potentially billable request.
|
||||
const message = cause instanceof Error ? cause.message : '测试请求未完成'
|
||||
if (!idempotencyKey) {
|
||||
setError(`${message}。测试尚未提交。`)
|
||||
return
|
||||
}
|
||||
if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey })
|
||||
else uncertainAttempts.current.delete(model.key)
|
||||
rememberAttempts()
|
||||
|
||||
29
docs/ai-model-browser-uuid-acceptance-2026-10-14.md
Normal file
29
docs/ai-model-browser-uuid-acceptance-2026-10-14.md
Normal file
@ -0,0 +1,29 @@
|
||||
# 模型测试请求编号修复验收
|
||||
|
||||
日期:2026-10-14。范围:AI 模型管理面板点击测试时的浏览器请求编号生成和提交前错误提示。
|
||||
|
||||
## 修复
|
||||
|
||||
- 使用浏览器原生 `crypto.getRandomValues()` 生成 UUID v4,兼容 HTTPS 和内网 HTTP;无新增依赖,不使用时间戳或 `Math.random()`。
|
||||
- 编号生成失败时明确提示“测试尚未提交”,不发送测试请求,不创建未知提交记录,不误导管理员核查未发生的模型调用。
|
||||
- 请求发出后的中断核查、联合测试、按用途开放和服务端超级管理员限制沿用现有流程。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 隔离发布预览来自 `21b6eb767c9fb1d140e69d763399381eb308d770` 的 Git 存档,仅覆盖本次修复;未混入详情页知识库等工作区改动。
|
||||
- 13 个相关测试文件、245 项测试全部通过,涵盖 UUID 格式与唯一性、版本和变体位、缺少安全随机数时拒绝提交、恢复后重试、异常核查和服务端权限。
|
||||
- 独立 HTTP 测试页由浏览器实际运行同一份 `lib/browser-uuid.ts` 转译代码。原生结果:`isSecureContext=false`、`crypto.randomUUID=undefined`、`crypto.getRandomValues=function`;100 个编号全部符合 UUID v4,且互不重复。无登录、密钥或模型调用。
|
||||
- HTTP 测试源码的 LF 规范化 SHA-256:`ba99717287a1d1c372871867ff0e56df5aa4090e5b02f1f32637d5153dcd9d4e`。
|
||||
- 本地隔离账号通过页面实际点击 Gemini `gemini-3-flash` 联合测试,分析、文案均通过;新增一次上游调用;相同请求编号再次提交没有新增调用。
|
||||
- 上游请求编号:`23e9e874-8383-4ded-8198-3e94ba13db6c`;联合执行编号:`a736553d-486f-4680-a458-0507671c2b86`。生产模型测试记录未改动。
|
||||
- 桌面 1440px、手机 390px 无横向溢出;确认、取消和完成状态正常,手机端取消未调用模型。截图保存在忽略目录 `output/ai-browser-uuid-20261014/`。
|
||||
|
||||
## Impeccable 范围审查
|
||||
|
||||
Operate 模式,保留现有界面。范围评分 19/20:可访问性 3、性能 4、响应式 4、主题一致性 4、实现完整性 4。新增错误提示使用现有 `role=alert`;不新增样式、动画、依赖或权限入口。面板检测器结果为 0 项,不代表整个管理后台完成全面 WCAG 审计。
|
||||
|
||||
## 限制与发布核对
|
||||
|
||||
浏览器自动化对 HTTP 别名地址的 DOM/截图读取超时;独立测试页的浏览器原生回执已收取,管理面板实际点击在本地回环地址完成。不能据此宣称已在生产 HTTP 页面实际点击模型。
|
||||
|
||||
发布使用干净的 Git 提交构建 Linux 镜像,无数据库迁移。上线后核对部署提交、健康检查、普通用户拒绝访问、模型配置与验证记录不变;生产验收不额外调用模型。上一版本镜像保留用于回滚,当前镜像传输包在校验后清理。
|
||||
20
lib/__tests__/browser-uuid.test.ts
Normal file
20
lib/__tests__/browser-uuid.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/** @jest-environment node */
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { z } from 'zod'
|
||||
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!
|
||||
afterEach(() => Object.defineProperty(globalThis, 'crypto', cryptoDescriptor))
|
||||
test('generates valid UUID v4 values without relying on randomUUID', () => {
|
||||
const original = globalThis.crypto
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { getRandomValues: original.getRandomValues.bind(original), randomUUID: undefined } })
|
||||
const ids = Array.from({ length: 100 }, () => createBrowserUuid())
|
||||
expect(new Set(ids).size).toBe(100)
|
||||
for (const id of ids) { expect(id).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/); expect(z.string().uuid().safeParse(id).success).toBe(true) }
|
||||
})
|
||||
test('sets the UUID version and variant without discarding the remaining random bits', () => {
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { getRandomValues: (bytes: Uint8Array) => bytes.fill(255) } })
|
||||
expect(createBrowserUuid()).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff')
|
||||
})
|
||||
test.each([undefined, {}])('missing secure randomness reports a clear error and never creates a weak identifier', value => {
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value })
|
||||
expect(createBrowserUuid).toThrow('当前浏览器无法生成请求编号,请更新浏览器后再试')
|
||||
})
|
||||
10
lib/browser-uuid.ts
Normal file
10
lib/browser-uuid.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/** Crypto.getRandomValues is also available on the platform's HTTP LAN origin. */
|
||||
export function createBrowserUuid(): string {
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (typeof cryptoApi?.getRandomValues !== 'function') throw new Error('当前浏览器无法生成请求编号,请更新浏览器后再试')
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user