Files
makelore/src/pages/Chat/GameAssetBrowser.tsx
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

252 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, GameAssetReviewAction>;
export function GameAssetBrowser(props: {
invocationId: string;
candidateIds?: string[];
onSubmit?: (assets: GameAssetCandidate[], decisions: GameAssetDecisionDraft) => void | Promise<void>;
}) {
const [assets, setAssets] = useState<GameAssetCandidate[]>([]);
const [review, setReview] = useState<GameAssetReviewSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [loadFailure, setLoadFailure] = useState<GameAssetReviewLoadFailure | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewAsset, setPreviewAsset] = useState<GameAssetCandidate | null>(null);
const [draftDecisions, setDraftDecisions] = useState<GameAssetDecisionDraft>({});
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<unknown>(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 <section data-testid="game-asset-browser" className="surface-card flex min-h-28 w-full items-center justify-center rounded-2xl border border-border/70 bg-background text-foreground shadow-none"><Loader2 className="h-5 w-5 animate-spin" /></section>;
}
if (loadFailure === 'stale') {
return (
<section
data-testid="game-asset-browser-stale"
className="flex min-h-24 w-full items-center rounded-md border border-foreground/15 bg-background px-4 py-3 text-foreground shadow-soft"
>
<p className="text-pretty text-sm font-medium"></p>
</section>
);
}
if (loadFailure === 'retryable') {
return (
<section
data-testid="game-asset-browser-retryable"
className="flex min-h-24 w-full items-center justify-between gap-3 rounded-md border border-foreground/15 bg-background px-4 py-3 text-foreground shadow-soft"
>
<p className="text-pretty text-sm font-medium"></p>
<Button
type="button"
className="h-10 shrink-0 border border-foreground/15 bg-brand-soft font-semibold text-foreground transition-transform active:scale-[0.985]"
onClick={() => void load()}
>
<RefreshCw className="mr-1 h-4 w-4" />
</Button>
</section>
);
}
if (review?.status === 'resolved' && review.candidateIds.length > 0) {
return (
<section data-testid="game-asset-review-summary" className="surface-card w-full rounded-2xl border border-border/70 bg-background px-3 py-3 text-foreground shadow-none">
<div className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
<div>
<p className="text-sm font-semibold"></p>
<p className="mt-1 text-xs font-medium leading-5 text-muted-foreground"> {approvedCount} {discardedCount} </p>
</div>
</div>
</section>
);
}
return (
<section data-testid="game-asset-browser" className="surface-card w-full rounded-2xl border border-border/70 bg-background px-3 py-3 text-foreground shadow-none">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="text-xs font-medium text-muted-foreground"></p>
</div>
{review ? <span className="rounded border border-foreground/15 bg-white px-2 py-1 text-[10px] font-semibold"> {review.pendingAssetIds.length} </span> : null}
</div>
{error ? <p className="mt-3 rounded border border-red-400 bg-red-50 p-2 text-xs font-medium text-red-700">{error}</p> : null}
{!error && assets.length === 0 ? (
<p className="mt-3 rounded border border-dashed border-foreground/15 bg-white p-3 text-xs font-medium">Agent </p>
) : null}
{assets.length > 0 ? (
<div className="mt-3 flex gap-3 overflow-x-auto pb-2" data-testid="game-asset-candidate-list">
{assets.map((asset) => {
const selectedAction = draftDecisions[asset.id];
return (
<article key={asset.id} className={`w-56 shrink-0 overflow-hidden rounded-md border border-foreground/15 bg-white shadow-soft ${selectedAction ? 'ring-4 ring-accent/40' : ''}`}>
<button type="button" className="flex h-28 w-full items-center justify-center overflow-hidden border-b border-foreground/15 bg-background" onClick={() => setPreviewAsset(asset)}>
{asset.previewDataUrl ? <img src={asset.previewDataUrl} alt={asset.name} className="h-full w-full object-contain" />
: asset.mediaKind === 'audio' ? <Headphones className="h-10 w-10 text-brand" />
: asset.mediaKind === 'bundle' ? <Archive className="h-10 w-10 text-accent" />
: <ImageIcon className="h-10 w-10 text-slate-400" />}
</button>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2"><strong className="line-clamp-2 text-xs">{asset.name}</strong><Badge className="border border-foreground/15 bg-surface-subtle text-[9px] text-foreground">{selectedAction ? actionLabel(selectedAction) : asset.status}</Badge></div>
<p className="line-clamp-2 text-[11px] font-medium">{asset.purpose || '未填写用途'}</p>
<p className="truncate text-[10px] text-muted-foreground">{asset.source || '未填写'}</p>
<p className="truncate text-[10px] text-muted-foreground">{asset.license || '未填写'}</p>
{asset.audioDataUrl ? <audio controls preload="metadata" className="h-8 w-full" src={asset.audioDataUrl} /> : null}
<div className="grid grid-cols-2 gap-2">
<Button type="button" size="sm" disabled={submitting} className={`h-8 border border-foreground/15 text-[11px] font-semibold text-foreground ${selectedAction === 'approve' ? 'bg-brand-soft' : 'bg-white'}`} aria-pressed={selectedAction === 'approve'} aria-label={`纳入开发 ${asset.name}`} onClick={() => chooseAction(asset.id, 'approve')}></Button>
<Button type="button" size="sm" variant="outline" disabled={submitting} className={`h-8 border border-foreground/15 text-[11px] font-semibold ${selectedAction === 'discard' ? 'bg-accent-soft' : ''}`} aria-pressed={selectedAction === 'discard'} aria-label={`舍弃 ${asset.name}`} onClick={() => chooseAction(asset.id, 'discard')}></Button>
</div>
<Button type="button" size="sm" variant="outline" disabled={submitting} className={`h-8 w-full border border-foreground/15 text-[11px] font-semibold ${selectedAction === 'replace' ? 'bg-surface-subtle' : ''}`} aria-pressed={selectedAction === 'replace'} aria-label={`重新寻找 ${asset.name}`} onClick={() => chooseAction(asset.id, 'replace')}><Search className="mr-1 h-3 w-3" /></Button>
</div>
</article>
);
})}
</div>
) : null}
{assets.length > 0 ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-foreground/15 pt-3">
<p className="text-[10px] font-medium text-muted-foreground"> {selectedCount} / {assets.length} {allAssetsSelected ? ',可以提交' : `,还需选择 ${assets.length - selectedCount}`}</p>
<Button type="button" size="sm" disabled={!allAssetsSelected || submitting} className="border border-foreground/15 bg-brand-soft text-[11px] font-semibold text-foreground" onClick={() => setShowSubmitDialog(true)}></Button>
</div>
) : null}
{review && (approvedCount > 0 || discardedCount > 0) ? <p className="mt-2 text-[10px] font-medium text-muted-foreground"> {approvedCount} {discardedCount} </p> : null}
{showSubmitDialog ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/20 p-5" role="dialog" aria-modal="true" aria-label="确认提交本轮素材审核结果">
<div className="max-h-[85vh] w-full max-w-2xl overflow-auto rounded-lg border border-foreground/15 bg-background p-5 shadow-soft">
<div className="flex items-start justify-between gap-3"><div><h3 className="text-lg font-semibold"></h3><p className="text-sm font-medium text-muted-foreground"></p></div><Button type="button" size="icon" variant="outline" aria-label="关闭提交确认" className="border border-foreground/15" onClick={() => setShowSubmitDialog(false)}><X className="h-4 w-4" /></Button></div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{(['approve', 'discard', 'replace'] as const).map((action) => {
const selected = assets.filter((asset) => draftDecisions[asset.id] === action);
return <div key={action} className="rounded border border-foreground/15 bg-white p-3 text-xs"><p className="font-semibold">{actionLabel(action)}{selected.length} </p><p className="mt-1 line-clamp-3 text-muted-foreground">{selected.map((asset) => asset.name).join('、') || '无'}</p></div>;
})}
</div>
<div className="mt-5 flex justify-end gap-2"><Button type="button" variant="outline" disabled={submitting} className="border border-foreground/15 font-semibold" onClick={() => setShowSubmitDialog(false)}></Button><Button type="button" disabled={submitting} className="border border-foreground/15 bg-brand-soft font-semibold text-foreground" onClick={() => void submitBatch()}>{submitting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}</Button></div>
</div>
</div>
) : null}
{previewAsset ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/20 p-5" role="dialog" aria-modal="true" aria-label={`${previewAsset.name} 素材预览`}>
<div className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-lg border border-foreground/15 bg-background p-5 shadow-soft">
<div className="flex items-start justify-between gap-3"><div><h3 className="text-lg font-semibold">{previewAsset.name}</h3><p className="text-sm font-medium text-muted-foreground">{previewAsset.purpose}</p></div><Button type="button" size="icon" variant="outline" aria-label="关闭素材预览" className="border border-foreground/15" onClick={() => setPreviewAsset(null)}><X className="h-4 w-4" /></Button></div>
{previewAsset.previewDataUrl ? <img src={previewAsset.previewDataUrl} alt={previewAsset.name} className="mt-4 max-h-[50vh] w-full rounded border border-foreground/15 bg-white object-contain" /> : null}
{previewAsset.audioDataUrl ? <audio controls autoPlay className="mt-4 w-full" src={previewAsset.audioDataUrl} /> : null}
{previewAsset.manifest.length > 0 ? <div className="mt-4"><h4 className="text-sm font-semibold"></h4><ul className="mt-2 max-h-52 overflow-auto rounded border border-foreground/15 bg-white p-3 text-xs">{previewAsset.manifest.map((item) => <li key={item} className="py-0.5">{item}</li>)}</ul></div> : null}
{previewAsset.unavailableReason ? <p className="mt-4 rounded border border-amber-400 bg-amber-50 p-3 text-xs font-medium">{previewAsset.unavailableReason}</p> : null}
</div>
</div>
) : null}
</section>
);
}