import { useCallback, useEffect, useState } from 'react'; import { Archive, Check, Headphones, Image as ImageIcon, Loader2, RefreshCw, Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { hostApiFetch } from '@/lib/host-api'; import type { GameAssetReviewAction } from '@/lib/game-asset-selection-message'; import { classifyGameAssetReviewLoadFailure, isGameAssetReviewResponse, type GameAssetCandidate, type GameAssetReviewLoadFailure, type GameAssetReviewSnapshot, } from './game-asset-review-recovery'; export type { GameAssetCandidate } from './game-asset-review-recovery'; type GameAssetDecisionDraft = Record; export function GameAssetBrowser(props: { invocationId: string; candidateIds?: string[]; onSubmit?: (assets: GameAssetCandidate[], decisions: GameAssetDecisionDraft) => void | Promise; }) { const [assets, setAssets] = useState([]); const [review, setReview] = useState(null); const [loading, setLoading] = useState(true); const [loadFailure, setLoadFailure] = useState(null); const [error, setError] = useState(null); const [previewAsset, setPreviewAsset] = useState(null); const [draftDecisions, setDraftDecisions] = useState({}); const [showSubmitDialog, setShowSubmitDialog] = useState(false); const [submitting, setSubmitting] = useState(false); const load = useCallback(async () => { setLoading(true); setLoadFailure(null); setError(null); const params = new URLSearchParams({ invocationId: props.invocationId }); if (props.candidateIds?.length) params.set('candidateIds', JSON.stringify(props.candidateIds)); const path = `/api/files/game-asset-review?${params.toString()}`; try { for (let attempt = 0; attempt < 2; attempt += 1) { try { const response = await hostApiFetch(path); if (!isGameAssetReviewResponse(response)) { setLoadFailure('stale'); return; } setReview(response.review); setAssets(response.assets); setDraftDecisions({}); setShowSubmitDialog(false); return; } catch (loadError) { const failure = classifyGameAssetReviewLoadFailure(loadError); if (failure === 'stale' || attempt === 1) { setLoadFailure(failure); return; } } } } finally { setLoading(false); } }, [props.candidateIds, props.invocationId]); useEffect(() => { void load(); }, [load]); const chooseAction = (assetId: string, action: GameAssetReviewAction) => { if (submitting) return; setDraftDecisions((current) => ({ ...current, [assetId]: action })); setError(null); }; const selectedCount = assets.filter((asset) => Boolean(draftDecisions[asset.id])).length; const allAssetsSelected = assets.length > 0 && selectedCount === assets.length; const actionLabel = (action: GameAssetReviewAction): string => ( action === 'approve' ? '纳入开发' : action === 'discard' ? '舍弃' : '重新寻找' ); const submitBatch = async () => { if (!review || !allAssetsSelected || submitting) return; setSubmitting(true); setError(null); const decisions = assets.flatMap((asset) => { const action = draftDecisions[asset.id]; return action ? [{ assetId: asset.id, action }] : []; }); try { const response = await hostApiFetch<{ success: boolean; review: GameAssetReviewSnapshot }>('/api/files/game-asset-review', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ invocationId: props.invocationId, candidateIds: review.candidateIds, decisions, }), }); setReview(response.review); setAssets([]); setDraftDecisions({}); setShowSubmitDialog(false); try { await props.onSubmit?.(assets, Object.fromEntries(decisions.map((item) => [item.assetId, item.action]))); } catch { setError('审核结果已保存,但暂时无法通知伙伴,请稍后继续对话'); } } catch { setShowSubmitDialog(false); setError('素材审核结果暂时无法保存,请稍后重试'); } finally { setSubmitting(false); } }; const approvedCount = review ? Object.values(review.decisions).filter((decision) => decision === 'approved').length : 0; const discardedCount = review ? Object.values(review.decisions).filter((decision) => decision !== 'approved').length : 0; if (loading) { return
; } if (loadFailure === 'stale') { return (

旧素材卡片已失效,已自动跳过,不影响继续对话

); } if (loadFailure === 'retryable') { return (

素材卡片暂时无法加载,不影响继续对话

); } if (review?.status === 'resolved' && review.candidateIds.length > 0) { return (

本轮审核已完成

纳入开发 {approvedCount} 个,舍弃 {discardedCount} 个。未处理素材不会自动带入下一轮。

); } return (

素材审核台

先逐项选择,全部选完后一次性提交审核结果。

{review ? 待处理 {review.pendingAssetIds.length} 个 : null}
{error ?

{error}

: null} {!error && assets.length === 0 ? (

Agent 本次没有提交可审核的素材。未处理内容不会自动从旧批次带入。

) : null} {assets.length > 0 ? (
{assets.map((asset) => { const selectedAction = draftDecisions[asset.id]; return (
{asset.name}{selectedAction ? actionLabel(selectedAction) : asset.status}

{asset.purpose || '未填写用途'}

来源:{asset.source || '未填写'}

授权:{asset.license || '未填写'}

{asset.audioDataUrl ?
); })}
) : null} {assets.length > 0 ? (

已选择 {selectedCount} / {assets.length} 个{allAssetsSelected ? ',可以提交' : `,还需选择 ${assets.length - selectedCount} 个`}

) : null} {review && (approvedCount > 0 || discardedCount > 0) ?

本轮已提交:纳入 {approvedCount} 个,舍弃 {discardedCount} 个。未处理素材不会自动带入下一轮。

: null} {showSubmitDialog ? (

确认提交本轮审核

将一次性保存全部决定,并只发送一条汇总对话。

{(['approve', 'discard', 'replace'] as const).map((action) => { const selected = assets.filter((asset) => draftDecisions[asset.id] === action); return

{actionLabel(action)}:{selected.length} 个

{selected.map((asset) => asset.name).join('、') || '无'}

; })}
) : null} {previewAsset ? (

{previewAsset.name}

{previewAsset.purpose}

{previewAsset.previewDataUrl ? {previewAsset.name} : null} {previewAsset.audioDataUrl ?
) : null}
); }