diff --git a/.gitignore b/.gitignore
index dd31b51..1bb4fca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.memory/worklog.json b/.memory/worklog.json
index dfb3bcc..933e5a2 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -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
}
]
}
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..aee7f29
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: { serverActions: { bodySizeLimit: '20mb' } },
+ images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
+};
+export default nextConfig;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dd9efce
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..e008c9c
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,3 @@
+export default {
+ plugins: { tailwindcss: {}, autoprefixer: {} },
+};
diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts
new file mode 100644
index 0000000..eb39491
--- /dev/null
+++ b/src/app/api/generate/route.ts
@@ -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);
+}
diff --git a/src/app/api/img/[bucket]/[filename]/route.ts b/src/app/api/img/[bucket]/[filename]/route.ts
new file mode 100644
index 0000000..dca5ba3
--- /dev/null
+++ b/src/app/api/img/[bucket]/[filename]/route.ts
@@ -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' },
+ });
+}
diff --git a/src/app/api/select/route.ts b/src/app/api/select/route.ts
new file mode 100644
index 0000000..1f80fda
--- /dev/null
+++ b/src/app/api/select/route.ts
@@ -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 });
+}
diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts
new file mode 100644
index 0000000..e2bfa22
--- /dev/null
+++ b/src/app/api/sessions/route.ts
@@ -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) });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..b00cc39
--- /dev/null
+++ b/src/app/globals.css
@@ -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; }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..f525d90
--- /dev/null
+++ b/src/app/layout.tsx
@@ -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 (
+
+
{children}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..17821de
--- /dev/null
+++ b/src/app/page.tsx
@@ -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([]);
+ const [current, setCurrent] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [provider, setProvider] = useState('?');
+
+ 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 (
+
+
setCurrent(sessions.find(s => s.id === id) ?? null)}
+ onNew={() => setCurrent(null)}
+ />
+
+
+
+
+
+ {current && (
+
+
+
本次生成 · {new Date(current.createdAt).toLocaleString('zh-CN')}
+ {current.id}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/PromptPanel.tsx b/src/components/PromptPanel.tsx
new file mode 100644
index 0000000..152197b
--- /dev/null
+++ b/src/components/PromptPanel.tsx
@@ -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([]);
+ const [count, setCount] = useState(8);
+ const [style, setStyle] = useState('');
+ const fileInput = useRef(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 (
+
+
+
+
+
+
+
+
+ {refs.map((r, i) => (
+
+

+
+
+ ))}
+ {refs.length < 4 && (
+
+ )}
+
handleFiles(e.target.files)}
+ />
+
+
+
+
+
+
+
+ {PRESET_STYLES.map(s => (
+
+ ))}
+
+
+
+
+
+
+
+ {[4, 8, 12].map(n => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ResultGrid.tsx b/src/components/ResultGrid.tsx
new file mode 100644
index 0000000..3d8d62c
--- /dev/null
+++ b/src/components/ResultGrid.tsx
@@ -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 (
+
+
+
+ 快捷键:数字键 1-{Math.min(9, images.length)} 选中 / Shift+数字键 打叉
+
+
+ 已选 {images.filter(i => i.status === 'selected').length} / {images.length}
+
+
+
+ {images.map((img, i) => (
+
+

+
{i + 1}
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..27a3488
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
new file mode 100644
index 0000000..14964bb
--- /dev/null
+++ b/src/lib/providers.ts
@@ -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 {
+ 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 = ``;
+ 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 {
+ 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);
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..26fe897
--- /dev/null
+++ b/src/lib/storage.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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}`;
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..207ea42
--- /dev/null
+++ b/src/lib/types.ts
@@ -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;
+};
+
+export type GenerateRequest = {
+ prompt: string;
+ refImages?: string[];
+ count: number;
+ style?: string;
+};
+
+export type GenerateResponse = {
+ sessionId: string;
+ images: GenImage[];
+ provider: 'mock' | 'poe' | 'openrouter';
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..3b72aee
--- /dev/null
+++ b/tailwind.config.ts
@@ -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;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..b75b514
--- /dev/null
+++ b/tsconfig.json
@@ -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"]
+}