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)} + /> +
+
+
+

AI 玩具专利生成工作流

+

批量出意向 → 快筛 → 多角度尺寸 → 喂专利

+
+
+ provider: {provider} + {provider === 'mock' && (未配 POE_API_KEY,当前是占位图)} +
+
+ +
+ + {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 ( +
+
+ +