auto-save 2026-05-18 10:44 (+6, ~2)

This commit is contained in:
2026-05-18 10:46:21 +08:00
parent 0accb73400
commit 494779dc13
20 changed files with 772 additions and 0 deletions

8
.gitignore vendored
View File

@@ -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

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};

View 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);
}

View 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' },
});
}

View 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 });
}

View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1,91 @@
// 生图 provider 抽象。优先 Poe nano-banana-profeedback_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 ![alt](url) 形式
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
View 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
View 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
View 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
View 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"]
}