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/
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
data/
|
||||
refs/*.pdf
|
||||
refs/*.docx
|
||||
refs/*.jpg
|
||||
refs/*.jpeg
|
||||
refs/*.png
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"message": "init: project scaffold",
|
||||
"hash": "5e4c6e5",
|
||||
"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