forked from natuka/web.puabadge.com
153 lines
4.7 KiB
TypeScript
153 lines
4.7 KiB
TypeScript
/**
|
||
* 原图分类 /classify(对齐 agent image-inspect)
|
||
* - 本地:Vite 代理 `/concept-classify` → `/v1/classify`
|
||
* - 线上:直连 seetacloud
|
||
*/
|
||
|
||
export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain'
|
||
|
||
export interface ImageInspect {
|
||
imageType: ImageKind
|
||
isCartoon: boolean
|
||
personCount: number
|
||
petCount: number
|
||
reviewStatus: string
|
||
conclusion: string
|
||
}
|
||
|
||
export class InspectCancelled extends Error {
|
||
constructor() {
|
||
super('cancelled')
|
||
this.name = 'InspectCancelled'
|
||
}
|
||
}
|
||
|
||
const INSPECT_TIMEOUT_MS = 120_000
|
||
const DEFAULT_INSPECT_HOST = 'https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443'
|
||
const DEFAULT_INSPECT_REMOTE_URL = `${DEFAULT_INSPECT_HOST}/v1/classify`
|
||
|
||
function asRecord(value: unknown) {
|
||
return value && typeof value === 'object' && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: null
|
||
}
|
||
|
||
export function mapInspectImageType(raw: unknown): ImageKind {
|
||
const text = String(raw ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
if (text === 'cartoon' || text === '1') return 'cartoon'
|
||
if (text === 'real_photo' || text === 'real' || text === '2') return 'real_photo'
|
||
if (text === 'other') return 'other'
|
||
return 'uncertain'
|
||
}
|
||
|
||
function resolveInspectUrl() {
|
||
const fromEnv = String(
|
||
(import.meta as any).env?.VITE_CONCEPT_INSPECT_URL || '',
|
||
)
|
||
.trim()
|
||
.replace(/\/+$/, '')
|
||
if (fromEnv) {
|
||
if (/\/v1\/classify$/i.test(fromEnv)) return fromEnv
|
||
return `${fromEnv}/v1/classify`
|
||
}
|
||
if ((import.meta as any).env?.DEV) return '/concept-classify'
|
||
return DEFAULT_INSPECT_REMOTE_URL
|
||
}
|
||
|
||
function resolveInspectToken() {
|
||
return String((import.meta as any).env?.VITE_CONCEPT_INSPECT_TOKEN || '').trim()
|
||
}
|
||
|
||
function countField(data: Record<string, unknown>, keys: string[]) {
|
||
for (const key of keys) {
|
||
const value = Number(data[key])
|
||
if (Number.isFinite(value)) return Math.max(0, Math.trunc(value))
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function normalizeInspect(body: unknown): ImageInspect {
|
||
const root = asRecord(body) || {}
|
||
const data = asRecord(root.data) || root
|
||
const imageType = mapInspectImageType(data.image_type ?? data.imageType)
|
||
const isCartoonRaw = data.is_cartoon ?? data.isCartoon
|
||
const isCartoon =
|
||
typeof isCartoonRaw === 'boolean' ? isCartoonRaw : imageType === 'cartoon'
|
||
return {
|
||
imageType,
|
||
isCartoon,
|
||
personCount: countField(data, [
|
||
'person_count',
|
||
'personCount',
|
||
'headcount',
|
||
'head_count',
|
||
]),
|
||
petCount: countField(data, ['pet_count', 'petCount']),
|
||
reviewStatus: String(data.review_status || ''),
|
||
conclusion: String(data.conclusion || ''),
|
||
}
|
||
}
|
||
|
||
export function isCartoonImage(result: ImageInspect) {
|
||
return result.isCartoon || result.imageType === 'cartoon'
|
||
}
|
||
|
||
export function figureTypeOf(result: ImageInspect) {
|
||
return result.imageType === 'real_photo' ? 1 : 0
|
||
}
|
||
|
||
export function formatSubjectFromInspect(result: ImageInspect) {
|
||
const parts: string[] = []
|
||
if (result.personCount > 0) parts.push(`${result.personCount}人`)
|
||
if (result.petCount > 0) parts.push(`${result.petCount}宠物`)
|
||
return parts.join('+')
|
||
}
|
||
|
||
export async function inspectImage(file: File, signal?: AbortSignal) {
|
||
if (signal?.aborted) throw new InspectCancelled()
|
||
const url = resolveInspectUrl()
|
||
const token = resolveInspectToken()
|
||
const form = new FormData()
|
||
form.append('image', file, file.name || 'image.jpg')
|
||
const headers: Record<string, string> = {}
|
||
if (token) headers.Authorization = `Bearer ${token}`
|
||
|
||
const controller = new AbortController()
|
||
let timedOut = false
|
||
const timer = window.setTimeout(() => {
|
||
timedOut = true
|
||
controller.abort()
|
||
}, INSPECT_TIMEOUT_MS)
|
||
const onAbort = () => controller.abort()
|
||
signal?.addEventListener('abort', onAbort, { once: true })
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
body: form,
|
||
headers,
|
||
signal: controller.signal,
|
||
})
|
||
if (!response.ok) {
|
||
if (response.status === 400) throw new Error('图片上传错误,请检查文件后重试')
|
||
if (response.status === 401) throw new Error('图片识别鉴权失败')
|
||
if (response.status === 413) throw new Error('图片过大,请压缩后重试')
|
||
throw new Error(`图片识别失败(${response.status})`)
|
||
}
|
||
const raw: unknown = await response.json()
|
||
return normalizeInspect(raw)
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||
if (timedOut) throw new Error('图片识别超时,请稍后重试')
|
||
throw new InspectCancelled()
|
||
}
|
||
if (error instanceof TypeError) throw new Error('图片识别失败,请稍后重试')
|
||
throw error instanceof Error ? error : new Error('图片识别失败')
|
||
} finally {
|
||
window.clearTimeout(timer)
|
||
signal?.removeEventListener('abort', onAbort)
|
||
}
|
||
}
|