auto-save 2026-05-18 10:44 (+6, ~2)
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,6 +1,14 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
.next/
|
||||||
|
next-env.d.ts
|
||||||
|
data/
|
||||||
|
refs/*.pdf
|
||||||
|
refs/*.docx
|
||||||
|
refs/*.jpg
|
||||||
|
refs/*.jpeg
|
||||||
|
refs/*.png
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
"message": "init: project scaffold",
|
"message": "init: project scaffold",
|
||||||
"hash": "5e4c6e5",
|
"hash": "5e4c6e5",
|
||||||
"files_changed": 6
|
"files_changed": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-18T10:39:25+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-18 10:39 (+1, ~1)",
|
||||||
|
"hash": "0accb73",
|
||||||
|
"files_changed": 4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: { serverActions: { bodySizeLimit: '20mb' } },
|
||||||
|
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-toy-patent-workflow",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4560",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 4560",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.0.3",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
postcss.config.mjs
Normal file
3
postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
62
src/app/api/generate/route.ts
Normal file
62
src/app/api/generate/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { detectProvider, generateMock, generatePoe } from '@/lib/providers';
|
||||||
|
import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage';
|
||||||
|
import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = (await req.json()) as GenerateRequest;
|
||||||
|
const { prompt, refImages = [], count = 8, style } = body;
|
||||||
|
|
||||||
|
if (!prompt || typeof prompt !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'prompt required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
||||||
|
const finalPrompt = style ? `${prompt}, style: ${style}` : prompt;
|
||||||
|
const provider = detectProvider();
|
||||||
|
|
||||||
|
const savedRefUrls: string[] = [];
|
||||||
|
for (let i = 0; i < refImages.length; i++) {
|
||||||
|
const r = refImages[i];
|
||||||
|
if (r.startsWith('data:')) {
|
||||||
|
savedRefUrls.push(await saveRefImage(sessionId, i, r));
|
||||||
|
} else {
|
||||||
|
savedRefUrls.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawImages;
|
||||||
|
try {
|
||||||
|
rawImages = provider === 'poe'
|
||||||
|
? await generatePoe({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
|
||||||
|
: await generateMock({ sessionId, prompt: finalPrompt, count });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 data URL 落盘成静态文件链接,便于后续选中复制
|
||||||
|
const images = await Promise.all(rawImages.map(async img => {
|
||||||
|
if (img.url.startsWith('data:')) {
|
||||||
|
const url = await saveGeneratedImage(sessionId, img.id, img.url);
|
||||||
|
return { ...img, url };
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session: GenSession = {
|
||||||
|
id: sessionId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
prompt: finalPrompt,
|
||||||
|
refImages: savedRefUrls,
|
||||||
|
count,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
await saveSession(session);
|
||||||
|
|
||||||
|
const resp: GenerateResponse = { sessionId, images, provider };
|
||||||
|
return NextResponse.json(resp);
|
||||||
|
}
|
||||||
19
src/app/api/img/[bucket]/[filename]/route.ts
Normal file
19
src/app/api/img/[bucket]/[filename]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { readImageFile } from '@/lib/storage';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string; filename: string }> }) {
|
||||||
|
const { bucket, filename } = await ctx.params;
|
||||||
|
if (!['generated', 'selected', 'refs'].includes(bucket)) {
|
||||||
|
return NextResponse.json({ error: 'bad bucket' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (filename.includes('..') || filename.includes('/')) {
|
||||||
|
return NextResponse.json({ error: 'bad filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs', filename);
|
||||||
|
if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||||
|
return new NextResponse(new Uint8Array(r.buf), {
|
||||||
|
headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
30
src/app/api/select/route.ts
Normal file
30
src/app/api/select/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { copyToSelected, loadSession, saveSession } from '@/lib/storage';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { sessionId, imageId, action } = (await req.json()) as {
|
||||||
|
sessionId: string;
|
||||||
|
imageId: string;
|
||||||
|
action: 'select' | 'reject' | 'reset';
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await loadSession(sessionId);
|
||||||
|
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const img = session.images.find(i => i.id === imageId);
|
||||||
|
if (!img) return NextResponse.json({ error: 'image not found' }, { status: 404 });
|
||||||
|
|
||||||
|
if (action === 'select') {
|
||||||
|
const newUrl = await copyToSelected(sessionId, imageId, img.url);
|
||||||
|
img.status = 'selected';
|
||||||
|
img.meta = { ...(img.meta ?? {}), selectedUrl: newUrl };
|
||||||
|
} else if (action === 'reject') {
|
||||||
|
img.status = 'rejected';
|
||||||
|
} else {
|
||||||
|
img.status = 'pending';
|
||||||
|
}
|
||||||
|
await saveSession(session);
|
||||||
|
return NextResponse.json({ ok: true, image: img });
|
||||||
|
}
|
||||||
10
src/app/api/sessions/route.ts
Normal file
10
src/app/api/sessions/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { listSessions } from '@/lib/storage';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
return NextResponse.json({ sessions: sessions.slice(0, 30) });
|
||||||
|
}
|
||||||
17
src/app/globals.css
Normal file
17
src/app/globals.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body { background: #0a0a0a; color: #fafafa; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.btn { @apply px-4 py-2 rounded-lg font-medium transition-all; }
|
||||||
|
.btn-primary { @apply bg-accent text-white hover:opacity-90 active:scale-95; }
|
||||||
|
.btn-ghost { @apply bg-white/5 hover:bg-white/10 border border-white/10; }
|
||||||
|
|
||||||
|
.card { @apply bg-white/[0.03] border border-white/10 rounded-xl; }
|
||||||
|
|
||||||
|
.tile { @apply relative aspect-square overflow-hidden rounded-xl border-2 border-transparent transition-all cursor-pointer; }
|
||||||
|
.tile-selected { @apply border-accent shadow-[0_0_30px_-5px_rgba(255,107,53,0.6)]; }
|
||||||
|
.tile-rejected { @apply opacity-20 grayscale; }
|
||||||
|
.tile-keynum { @apply absolute top-2 left-2 w-7 h-7 rounded-md bg-black/60 backdrop-blur text-xs font-bold flex items-center justify-center; }
|
||||||
15
src/app/layout.tsx
Normal file
15
src/app/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import './globals.css';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'AI玩具专利生成工作流',
|
||||||
|
description: '批量出意向 → 快筛 → 多角度尺寸 → 专利申请',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="zh">
|
||||||
|
<body className="min-h-screen">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/page.tsx
Normal file
97
src/app/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import PromptPanel from '@/components/PromptPanel';
|
||||||
|
import ResultGrid from '@/components/ResultGrid';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import type { GenImage, GenSession, GenerateResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [sessions, setSessions] = useState<GenSession[]>([]);
|
||||||
|
const [current, setCurrent] = useState<GenSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [provider, setProvider] = useState<string>('?');
|
||||||
|
|
||||||
|
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 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({
|
||||||
|
...current,
|
||||||
|
images: current.images.map(i => i.id === imageId ? d.image : i),
|
||||||
|
});
|
||||||
|
refreshSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar
|
||||||
|
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">
|
||||||
|
<header className="px-8 py-6 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">AI 玩具专利生成工作流</h1>
|
||||||
|
<p className="text-xs text-white/40 mt-1">批量出意向 → 快筛 → 多角度尺寸 → 喂专利</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/40">
|
||||||
|
provider: <span className={provider === 'poe' ? 'text-accent' : 'text-yellow-400'}>{provider}</span>
|
||||||
|
{provider === 'mock' && <span className="ml-2 text-yellow-400/80">(未配 POE_API_KEY,当前是占位图)</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-6 max-w-6xl">
|
||||||
|
<PromptPanel onGenerate={handleGenerate} loading={loading} />
|
||||||
|
{current && (
|
||||||
|
<section className="card p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm text-white/60">本次生成 · {new Date(current.createdAt).toLocaleString('zh-CN')}</h2>
|
||||||
|
<code className="text-xs text-white/30">{current.id}</code>
|
||||||
|
</div>
|
||||||
|
<ResultGrid images={current.images} onAction={handleAction} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/PromptPanel.tsx
Normal file
123
src/components/PromptPanel.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const PRESET_STYLES = [
|
||||||
|
{ id: 'plush', label: '毛绒玩偶' },
|
||||||
|
{ id: 'mecha', label: '机甲风' },
|
||||||
|
{ id: 'kawaii', label: '可爱萌系' },
|
||||||
|
{ id: 'blueprint', label: '专利蓝图' },
|
||||||
|
{ id: 'cyber', label: '赛博朋克' },
|
||||||
|
{ id: 'minimal', label: '极简' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type PromptPanelProps = {
|
||||||
|
onGenerate: (opts: { prompt: string; refImages: string[]; count: number; style?: string }) => void;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PromptPanel({ onGenerate, loading }: PromptPanelProps) {
|
||||||
|
const [prompt, setPrompt] = useState('AI 毛绒陪伴玩具,机甲头盔,胸前挂 M logo,橙白配色,圆胖体型');
|
||||||
|
const [refs, setRefs] = useState<string[]>([]);
|
||||||
|
const [count, setCount] = useState(8);
|
||||||
|
const [style, setStyle] = useState<string>('');
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleFiles(files: FileList | null) {
|
||||||
|
if (!files) return;
|
||||||
|
Array.from(files).slice(0, 4 - refs.length).forEach(f => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => setRefs(prev => [...prev, r.result as string]);
|
||||||
|
r.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!prompt.trim() || loading) return;
|
||||||
|
onGenerate({ prompt: prompt.trim(), refImages: refs, count, style: style || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-white/60 mb-2">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={e => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
|
||||||
|
rows={4}
|
||||||
|
placeholder="描述要生成的玩具意向…(⌘/Ctrl+Enter 提交)"
|
||||||
|
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm resize-none focus:border-accent outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-white/60 mb-2">参考图(可选,最多 4 张)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{refs.map((r, i) => (
|
||||||
|
<div key={i} className="relative w-20 h-20 rounded-lg overflow-hidden border border-white/10">
|
||||||
|
<img src={r} alt="ref" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
onClick={() => setRefs(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
className="absolute top-0.5 right-0.5 w-5 h-5 rounded bg-black/70 text-xs"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{refs.length < 4 && (
|
||||||
|
<button
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
className="w-20 h-20 rounded-lg border border-dashed border-white/20 hover:border-accent text-white/40 hover:text-accent text-2xl"
|
||||||
|
>+</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={e => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-white/60 mb-2">风格</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStyle('')}
|
||||||
|
className={`btn text-xs ${style === '' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
>无</button>
|
||||||
|
{PRESET_STYLES.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setStyle(s.label)}
|
||||||
|
className={`btn text-xs ${style === s.label ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
>{s.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-white/60 mb-2">数量</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[4, 8, 12].map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setCount(n)}
|
||||||
|
className={`btn text-xs ${count === n ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
>{n}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading || !prompt.trim()}
|
||||||
|
className="btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? '生成中…' : '🪄 批量生成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/ResultGrid.tsx
Normal file
63
src/components/ResultGrid.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { GenImage } from '@/lib/types';
|
||||||
|
|
||||||
|
export type ResultGridProps = {
|
||||||
|
images: GenImage[];
|
||||||
|
onAction: (imageId: string, action: 'select' | 'reject' | 'reset') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResultGrid({ images, onAction }: ResultGridProps) {
|
||||||
|
// 键盘:1-9 切换选中,shift+1-9 打叉
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: KeyboardEvent) {
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (isNaN(n) || n < 1 || n > 9 || n > images.length) return;
|
||||||
|
const img = images[n - 1];
|
||||||
|
if (!img) return;
|
||||||
|
const action = e.shiftKey
|
||||||
|
? (img.status === 'rejected' ? 'reset' : 'reject')
|
||||||
|
: (img.status === 'selected' ? 'reset' : 'select');
|
||||||
|
onAction(img.id, action);
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [images, onAction]);
|
||||||
|
|
||||||
|
const cols = images.length <= 4 ? 'grid-cols-2' : images.length <= 9 ? 'grid-cols-3' : 'grid-cols-4';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-white/40">
|
||||||
|
<span>
|
||||||
|
快捷键:数字键 1-{Math.min(9, images.length)} 选中 / Shift+数字键 打叉
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
已选 <span className="text-accent font-bold">{images.filter(i => i.status === 'selected').length}</span> / {images.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`grid ${cols} gap-3`}>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
className={`tile ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
|
||||||
|
>
|
||||||
|
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-cover" />
|
||||||
|
<div className="tile-keynum">{i + 1}</div>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2 opacity-0 hover:opacity-100 transition-opacity bg-gradient-to-t from-black/80 to-transparent">
|
||||||
|
<button
|
||||||
|
onClick={() => onAction(img.id, img.status === 'selected' ? 'reset' : 'select')}
|
||||||
|
className="btn btn-primary text-xs flex-1"
|
||||||
|
>{img.status === 'selected' ? '✓ 已选' : '✓ 选中'}</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAction(img.id, img.status === 'rejected' ? 'reset' : 'reject')}
|
||||||
|
className="btn btn-ghost text-xs"
|
||||||
|
>✗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/Sidebar.tsx
Normal file
47
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { GenSession } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function Sidebar({
|
||||||
|
sessions,
|
||||||
|
currentId,
|
||||||
|
onPick,
|
||||||
|
onNew,
|
||||||
|
}: {
|
||||||
|
sessions: GenSession[];
|
||||||
|
currentId: string | null;
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="w-64 shrink-0 border-r border-white/10 bg-black/40 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-white/10">
|
||||||
|
<button onClick={onNew} className="btn btn-primary w-full text-sm">+ 新会话</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-xs text-white/40 uppercase tracking-wider">最近</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-4 space-y-1">
|
||||||
|
{sessions.length === 0 && <div className="px-3 py-4 text-xs text-white/30">还没有生成记录</div>}
|
||||||
|
{sessions.map(s => {
|
||||||
|
const selectedCount = s.images.filter(i => i.status === 'selected').length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onPick(s.id)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${
|
||||||
|
currentId === s.id ? 'bg-white/10' : 'hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="line-clamp-2 text-white/80">{s.prompt}</div>
|
||||||
|
<div className="mt-1 text-white/40 flex justify-between">
|
||||||
|
<span>{new Date(s.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
<span>
|
||||||
|
{s.images.length} 张 · <span className="text-accent">✓{selectedCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/lib/providers.ts
Normal file
91
src/lib/providers.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// 生图 provider 抽象。优先 Poe nano-banana-pro(feedback_image-gen-model),
|
||||||
|
// 没 Key 时 mock。OpenRouter 当前模型 google/gemini-3.1-pro-preview 是文本模型,
|
||||||
|
// 不能生图,所以回退是 mock 不是 OpenRouter。
|
||||||
|
|
||||||
|
import type { GenImage } from './types';
|
||||||
|
|
||||||
|
export type Provider = 'mock' | 'poe';
|
||||||
|
|
||||||
|
export function detectProvider(): Provider {
|
||||||
|
return process.env.POE_API_KEY ? 'poe' : 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock:返回 SVG 占位图(data URL),不真的生图
|
||||||
|
export async function generateMock(opts: {
|
||||||
|
sessionId: string;
|
||||||
|
prompt: string;
|
||||||
|
count: number;
|
||||||
|
}): Promise<GenImage[]> {
|
||||||
|
const palette = ['#ff6b35', '#3b82f6', '#10b981', '#a855f7', '#f59e0b', '#ec4899', '#06b6d4', '#84cc16', '#ef4444'];
|
||||||
|
return Array.from({ length: opts.count }).map((_, i) => {
|
||||||
|
const c = palette[i % palette.length];
|
||||||
|
const c2 = palette[(i + 3) % palette.length];
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs><linearGradient id="g${i}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${c}"/><stop offset="100%" stop-color="${c2}"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<rect width="512" height="512" fill="url(#g${i})"/>
|
||||||
|
<circle cx="256" cy="220" r="120" fill="white" opacity="0.85"/>
|
||||||
|
<ellipse cx="220" cy="200" rx="10" ry="14" fill="#111"/>
|
||||||
|
<ellipse cx="292" cy="200" rx="10" ry="14" fill="#111"/>
|
||||||
|
<path d="M210 250 Q256 285 302 250" stroke="#111" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||||
|
<rect x="160" y="340" width="192" height="130" rx="20" fill="white" opacity="0.85"/>
|
||||||
|
<text x="256" y="500" text-anchor="middle" font-family="monospace" font-size="20" font-weight="bold" fill="white">MOCK #${i + 1}</text>
|
||||||
|
</svg>`;
|
||||||
|
const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||||
|
return {
|
||||||
|
id: `img_${opts.sessionId}_${i}`,
|
||||||
|
url: dataUrl,
|
||||||
|
prompt: opts.prompt,
|
||||||
|
status: 'pending' as const,
|
||||||
|
meta: { provider: 'mock', index: i },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poe nano-banana-pro 生图
|
||||||
|
export async function generatePoe(opts: {
|
||||||
|
sessionId: string;
|
||||||
|
prompt: string;
|
||||||
|
count: number;
|
||||||
|
refImages?: string[];
|
||||||
|
}): Promise<GenImage[]> {
|
||||||
|
const key = process.env.POE_API_KEY;
|
||||||
|
if (!key) throw new Error('POE_API_KEY missing');
|
||||||
|
|
||||||
|
const messages: Array<{ role: string; content: unknown }> = [];
|
||||||
|
if (opts.refImages && opts.refImages.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: opts.prompt },
|
||||||
|
...opts.refImages.map(url => ({ type: 'image_url', image_url: { url } })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.push({ role: 'user', content: opts.prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = Array.from({ length: opts.count }).map(async (_, i) => {
|
||||||
|
const res = await fetch('https://api.poe.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||||||
|
body: JSON.stringify({ model: 'nano-banana-pro', messages, stream: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Poe ${res.status}: ${await res.text()}`);
|
||||||
|
const data: { choices?: Array<{ message?: { content?: string } }> } = await res.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content || '';
|
||||||
|
// Poe 返回里图片通常以 markdown  形式
|
||||||
|
const m = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/) || content.match(/(https?:\/\/[^\s)]+\.(?:png|jpe?g|webp))/i);
|
||||||
|
const url = m ? m[1] : '';
|
||||||
|
return {
|
||||||
|
id: `img_${opts.sessionId}_${i}`,
|
||||||
|
url,
|
||||||
|
prompt: opts.prompt,
|
||||||
|
status: 'pending' as const,
|
||||||
|
meta: { provider: 'poe', index: i, raw: content.slice(0, 200) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(calls);
|
||||||
|
}
|
||||||
83
src/lib/storage.ts
Normal file
83
src/lib/storage.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { GenSession } from './types';
|
||||||
|
|
||||||
|
const ROOT = path.join(process.cwd(), 'data');
|
||||||
|
const SESS_DIR = path.join(ROOT, 'sessions');
|
||||||
|
const SEL_DIR = path.join(ROOT, 'selected');
|
||||||
|
const REF_DIR = path.join(ROOT, 'refs');
|
||||||
|
const GEN_DIR = path.join(ROOT, 'generated');
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR].map(d => fs.mkdir(d, { recursive: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(s: GenSession) {
|
||||||
|
await ensureDirs();
|
||||||
|
await fs.writeFile(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSession(id: string): Promise<GenSession | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(SESS_DIR, `${id}.json`), 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<GenSession[]> {
|
||||||
|
await ensureDirs();
|
||||||
|
const files = await fs.readdir(SESS_DIR);
|
||||||
|
const all = await Promise.all(
|
||||||
|
files.filter(f => f.endsWith('.json')).map(async f => {
|
||||||
|
const raw = await fs.readFile(path.join(SESS_DIR, f), 'utf-8');
|
||||||
|
return JSON.parse(raw) as GenSession;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return all.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGeneratedImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
|
||||||
|
await ensureDirs();
|
||||||
|
const m = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||||
|
if (!m) throw new Error('Invalid data URL');
|
||||||
|
const ext = m[1] === 'jpeg' ? 'jpg' : m[1];
|
||||||
|
const file = path.join(GEN_DIR, `${sessionId}_${imageId}.${ext}`);
|
||||||
|
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
||||||
|
return `/api/img/generated/${sessionId}_${imageId}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
|
||||||
|
await ensureDirs();
|
||||||
|
// srcUrl 形如 /api/img/generated/xxx.png
|
||||||
|
const match = srcUrl.match(/\/api\/img\/generated\/(.+)$/);
|
||||||
|
if (!match) return srcUrl;
|
||||||
|
const filename = match[1];
|
||||||
|
const src = path.join(GEN_DIR, filename);
|
||||||
|
const dst = path.join(SEL_DIR, filename);
|
||||||
|
await fs.copyFile(src, dst);
|
||||||
|
return `/api/img/selected/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readImageFile(bucket: 'generated' | 'selected' | 'refs', filename: string): Promise<{ buf: Buffer; type: string } | null> {
|
||||||
|
try {
|
||||||
|
const dir = bucket === 'generated' ? GEN_DIR : bucket === 'selected' ? SEL_DIR : REF_DIR;
|
||||||
|
const buf = await fs.readFile(path.join(dir, filename));
|
||||||
|
const ext = path.extname(filename).slice(1).toLowerCase();
|
||||||
|
const type = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
|
||||||
|
return { buf, type };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRefImage(sessionId: string, idx: number, dataUrl: string): Promise<string> {
|
||||||
|
await ensureDirs();
|
||||||
|
const m = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||||
|
if (!m) throw new Error('Invalid ref data URL');
|
||||||
|
const ext = m[1] === 'jpeg' ? 'jpg' : m[1];
|
||||||
|
const file = path.join(REF_DIR, `${sessionId}_ref${idx}.${ext}`);
|
||||||
|
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
||||||
|
return `/api/img/refs/${sessionId}_ref${idx}.${ext}`;
|
||||||
|
}
|
||||||
29
src/lib/types.ts
Normal file
29
src/lib/types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type GenSession = {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
prompt: string;
|
||||||
|
refImages: string[];
|
||||||
|
count: number;
|
||||||
|
images: GenImage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenImage = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
prompt: string;
|
||||||
|
status: 'pending' | 'selected' | 'rejected';
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateRequest = {
|
||||||
|
prompt: string;
|
||||||
|
refImages?: string[];
|
||||||
|
count: number;
|
||||||
|
style?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateResponse = {
|
||||||
|
sessionId: string;
|
||||||
|
images: GenImage[];
|
||||||
|
provider: 'mock' | 'poe' | 'openrouter';
|
||||||
|
};
|
||||||
16
tailwind.config.ts
Normal file
16
tailwind.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ink: '#0a0a0a',
|
||||||
|
paper: '#fafafa',
|
||||||
|
accent: '#ff6b35',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user