Files
ai-toy-patent-workflow/src/app/page.tsx

386 lines
14 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.
'use client';
import { useCallback, useEffect, useState } from 'react';
import PromptPanel from '@/components/PromptPanel';
import ResultGrid from '@/components/ResultGrid';
import Sidebar from '@/components/Sidebar';
import PackPanel from '@/components/PackPanel';
import { OasisCanvas } from '@/components/login/OasisCanvas';
import type {
GenImage,
GenSession,
GenerateAllPacksResponse,
GeneratePackResponse,
GenerateResponse,
LockCharacterResponse,
PackKind,
ProjectFromUploadResponse,
RegenerateAssetResponse,
UploadImageResponse,
UploadedImageRole,
VideoGenerationResponse,
} from '@/lib/types';
import type { VIDEO_TEMPLATES } from '@/lib/templates';
export default function Home() {
const [sessions, setSessions] = useState<GenSession[]>([]);
const [current, setCurrent] = useState<GenSession | null>(null);
const [loading, setLoading] = useState(false);
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
const [allLoading, setAllLoading] = useState(false);
const [characterLoading, setCharacterLoading] = useState(false);
const [videoLoading, setVideoLoading] = useState(false);
const [uploadLoading, setUploadLoading] = useState(false);
const [provider, setProvider] = useState<string>('?');
const [sidebarOpen, setSidebarOpen] = useState(true);
const refreshSessions = useCallback(async () => {
const r = await fetch('/api/sessions');
const d = await r.json();
setSessions(d.sessions);
return d.sessions as GenSession[];
}, []);
useEffect(() => { refreshSessions(); }, [refreshSessions]);
async function handleGenerate(opts: { prompt: string; refImages: string[]; count: number; style?: string }) {
setLoading(true);
try {
const r = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(opts),
});
if (!r.ok) {
alert('生成失败:' + (await r.text()));
return;
}
const d: GenerateResponse = await r.json();
setProvider(d.provider);
const all = await refreshSessions();
const s = all.find(x => x.id === d.sessionId) ?? null;
setCurrent(s);
} finally {
setLoading(false);
}
}
async function uploadImage(file: File, role: UploadedImageRole) {
const form = new FormData();
form.set('image', file);
form.set('role', role);
const r = await fetch('/api/uploads', { method: 'POST', body: form });
if (!r.ok) throw new Error(await r.text());
const d: UploadImageResponse = await r.json();
return d.uploadedImage;
}
async function handleUploadProject(opts: {
mode: 'remix' | 'replicate';
files: Array<{ file: File; role: 'reference' | 'subject' }>;
prompt?: string;
styleId?: string;
count?: number;
}) {
if (uploadLoading) return;
setUploadLoading(true);
try {
const uploadedImages = await Promise.all(opts.files.map(item => uploadImage(item.file, item.role)));
const r = await fetch('/api/projects/from-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadedImages,
mode: opts.mode,
remixPrompt: opts.mode === 'remix' ? opts.prompt : undefined,
userHint: opts.mode === 'replicate' ? opts.prompt : undefined,
styleId: opts.styleId,
count: opts.count,
}),
});
if (!r.ok) {
alert('上传项目创建失败:' + (await r.text()));
return;
}
const d: ProjectFromUploadResponse = await r.json();
setProvider(d.provider);
const all = await refreshSessions();
const s = all.find(x => x.id === d.sessionId) ?? null;
setCurrent(s);
} catch (error) {
alert('上传失败:' + String(error));
} finally {
setUploadLoading(false);
}
}
async function handleAction(imageId: string, action: 'select' | 'reject' | 'reset') {
if (!current) return;
const r = await fetch('/api/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId, action }),
});
if (!r.ok) return;
const d: { image: GenImage } = await r.json();
setCurrent(prev => prev ? {
...prev,
images: prev.images.map(i => i.id === imageId ? d.image : i),
} : prev);
refreshSessions();
}
async function handleGeneratePack(image: GenImage, kind: PackKind) {
if (!current || loadingKind) return;
setLoadingKind(kind);
try {
const r = await fetch('/api/packs/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId: image.id, kind, background: true }),
});
if (!r.ok) {
alert('素材包生成失败:' + (await r.text()));
return;
}
const d: GeneratePackResponse = await r.json();
setProvider(d.provider);
const all = await refreshSessions();
const updated = all.find(x => x.id === current.id) ?? null;
setCurrent(updated);
scheduleSessionRefresh(current.id);
} finally {
setLoadingKind(null);
}
}
async function reloadCurrent(sessionId: string) {
const all = await refreshSessions();
const updated = all.find(x => x.id === sessionId) ?? null;
setCurrent(updated);
return updated;
}
function scheduleSessionRefresh(sessionId: string, remaining = 90) {
if (remaining <= 0) return;
window.setTimeout(async () => {
const updated = await reloadCurrent(sessionId);
const hasDraftPack = updated?.packs?.some(pack => pack.status === 'draft') ?? false;
if (hasDraftPack || remaining > 84) scheduleSessionRefresh(sessionId, remaining - 1);
}, 5000);
}
async function handleLockCharacter(image: GenImage) {
if (!current || characterLoading) return;
setCharacterLoading(true);
try {
const r = await fetch('/api/character/lock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId: image.id, force: true }),
});
if (!r.ok) {
alert('角色锁定失败:' + (await r.text()));
return;
}
const d: LockCharacterResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
} finally {
setCharacterLoading(false);
}
}
async function handleGenerateAll(image: GenImage) {
if (!current || allLoading) return;
setAllLoading(true);
try {
const r = await fetch('/api/packs/generate-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, imageId: image.id, background: true }),
});
if (!r.ok) {
alert('完整三包生成失败:' + (await r.text()));
return;
}
const d: GenerateAllPacksResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
scheduleSessionRefresh(current.id);
} finally {
setAllLoading(false);
}
}
async function handleRegenerateAsset(assetId: string, userRefinement?: string) {
if (!current) return;
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }),
});
if (!r.ok) {
alert('单张重做失败:' + (await r.text()));
return;
}
const d: RegenerateAssetResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
}
function resolveVideoAnchor(image: GenImage) {
const packs = current?.packs ?? [];
const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front');
const patentFront = packs.find(pack => pack.kind === 'patent')?.assets.find(asset => asset.templateId === 'patent_front');
const cleanReference = current?.characterSpec?.cleanReferenceImageUrl;
if (mktFront) return { url: mktFront.url, label: '宣发白底图' };
if (patentFront) return { url: patentFront.url, label: '专利主图' };
if (cleanReference) return { url: cleanReference, label: 'L1 白底锚图' };
return { url: image.url, label: '意向图' };
}
async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) {
if (!current || videoLoading) return;
setVideoLoading(true);
try {
const character = current.characterSpec
? `${current.characterSpec.name}${current.characterSpec.oneLiner}`
: current.prompt;
const prompt = template.promptTemplate.replace('{character}', character);
const anchor = resolveVideoAnchor(image);
const r = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
imageUrl: anchor.url,
duration: template.duration,
ratio: template.ratio,
generateAudio: true,
watermark: false,
}),
});
if (!r.ok) {
alert('Seedance 视频提交失败:' + (await r.text()));
return;
}
const d: VideoGenerationResponse = await r.json();
alert(`Seedance 任务已提交:${d.taskId ?? d.status};参考:${anchor.label}`);
} finally {
setVideoLoading(false);
}
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
}
return (
<div className="app-oasis relative h-screen overflow-hidden text-white">
<OasisCanvas />
<div className="app-oasis-shade" />
<div className="app-grass-floor" />
<div className="relative z-10 flex h-screen text-white">
<Sidebar
open={sidebarOpen}
onToggle={() => setSidebarOpen(v => !v)}
sessions={sessions}
currentId={current?.id ?? null}
onPick={id => setCurrent(sessions.find(s => s.id === id) ?? null)}
onNew={() => setCurrent(null)}
/>
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-[1240px] px-10 py-8">
<header className="flex items-center justify-between mb-8 gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-[8px] bg-gradient-to-br from-[#e6f578] to-[#d6b36a] flex items-center justify-center shrink-0 shadow-glow-violet">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#081006" strokeWidth="2.5">
<path d="M12 2l2.5 6.5L21 11l-6.5 2.5L12 20l-2.5-6.5L3 11l6.5-2.5z" strokeLinejoin="round" />
</svg>
</div>
<div className="min-w-0">
<h1 className="text-base font-semibold tracking-tight leading-tight text-[#f8f7ef]">
AI Toy Patent
<span className="ml-2 text-white/48 font-normal text-sm"> / / </span>
</h1>
<p className="text-[11px] text-[#e6f578]/55 mt-0.5"> · · · · Seedance </p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{current && (
<>
<a
href={`/api/gallery/${encodeURIComponent(current.id)}`}
target="_blank"
rel="noreferrer"
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
>
</a>
<a
href={`/api/audit/${encodeURIComponent(current.id)}`}
target="_blank"
rel="noreferrer"
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
>
</a>
</>
)}
<button
onClick={handleLogout}
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
>
退
</button>
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
</span>
</div>
</header>
<div className="space-y-8">
<PromptPanel
onGenerate={handleGenerate}
onUploadProject={handleUploadProject}
loading={loading}
uploadLoading={uploadLoading}
/>
{current && (
<section className="space-y-5">
<div className="flex items-end justify-between">
<div>
<span className="section-eyebrow">Step · 02 · Quick Screen</span>
<h2 className="mt-2 text-lg font-semibold text-white"></h2>
<p className="text-xs text-white/40 mt-1">
{new Date(current.createdAt).toLocaleString('zh-CN')}
</p>
</div>
<code className="text-[11px] text-white/30 font-mono">{current.id}</code>
</div>
<ResultGrid images={current.images} onAction={handleAction} />
<PackPanel
session={current}
loadingKind={loadingKind}
allLoading={allLoading}
characterLoading={characterLoading}
videoLoading={videoLoading}
onGenerate={handleGeneratePack}
onGenerateAll={handleGenerateAll}
onLockCharacter={handleLockCharacter}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}
/>
</section>
)}
</div>
</div>
</main>
</div>
</div>
);
}