diff --git a/Dockerfile b/Dockerfile index cf8c16f..8bd0599 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,8 @@ COPY --from=build --chown=node:node /app/.next/standalone/ ./ COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./ COPY --from=build --chown=node:node /app/db/ ./db/ COPY --chown=node:node scripts/container-health.mjs ./ +COPY --chown=node:node scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs +COPY --chown=node:node seed/characters/ ./seed/characters/ USER node EXPOSE 3000 CMD ["node", "server.js"] diff --git a/README.md b/README.md index 4503c2b..57f05f2 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,11 @@ PROMPT_IMAGE=prompt:local PROMPT_TEST_IMAGE=prompt:tests bash scripts/integratio ``` 整个验证过程无需在宿主机安装 Node.js、PostgreSQL、S3 或 FFmpeg。runner 仅需 Docker Engine、Compose、Git、Bash、flock 和管理 Docker/旧服务所需的权限。 + +## 街头小人技能与 Agent 接口 + +导航「工具 → 技能」打开 `/tools/skills`,`/tools/skills/street-people` 包含风格约定、提示词、上传示例和两个独立图包入口。 + +把 `GET /api/skills/street-people` 的完整地址交给有绘图工具的 coding agent。GET 返回 JSON 规范,`?format=md` 返回 Markdown。agent 用自己的绘图能力生成单人透明 PNG,再向同一地址 POST multipart/form-data,字段 `files` 可重复,`title`、`description`、`tags` 和 `slug` 可选。网站不调用外部模型,也不需要模型密钥。PNG 必须有真实透明与接近不透明的像素(alpha ≥250),最多 4000 万像素,其余上传数量和大小限制与导入页一致。成功返回 post 地址和可复用素材 ID。 + +`seed/characters` 存放本次参考图提取 15 张、新生成测试 4 张及来源说明。部署健康检查后通过 `scripts/import-character-packs.mjs` 调用真实上传接口写入 PostgreSQL/S3,生成 `/p/street-people-reference` 与 `/p/street-people-original` 两个独立 post。已有同 slug 时跳过,重发版不重复导入。原图提取由 imagegen 执行,可能重建细节,并非逐像素无损裁剪。 diff --git a/public/skills/street-people/reference.png b/public/skills/street-people/reference.png new file mode 100644 index 0000000..2bc6e79 Binary files /dev/null and b/public/skills/street-people/reference.png differ diff --git a/public/skills/street-people/sample.png b/public/skills/street-people/sample.png new file mode 100644 index 0000000..5f5af21 Binary files /dev/null and b/public/skills/street-people/sample.png differ diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c315d8f..8851a89 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -75,6 +75,7 @@ if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl di compose up -d --no-build --wait --wait-timeout 180 web worker compose exec -T web node container-health.mjs compose exec -T web node -e "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +compose exec -T web node scripts/import-character-packs.mjs compose exec -T worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null' compose exec -T web node -e "(async()=>{const feed=await fetch('http://127.0.0.1:3000/api/feed?section=articles').then(r=>r.json());const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];for(const slug of slugs){const post=feed.posts.find(p=>p.slug===slug);if(!post)throw Error('Published article missing: '+slug);const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug)}if(new Set(feed.posts.filter(p=>slugs.includes(p.slug)).map(p=>p.cover_asset_id)).size!==1)throw Error('Series must share one cover');console.log('Verified three published article pages and their shared cover')})().catch(e=>{console.error(e.message);process.exit(1)})" ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next" diff --git a/scripts/import-character-packs.mjs b/scripts/import-character-packs.mjs new file mode 100644 index 0000000..91abc98 --- /dev/null +++ b/scripts/import-character-packs.mjs @@ -0,0 +1,29 @@ +import {readFile,readdir} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import {join,dirname} from 'node:path'; +const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters'); +const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000'; +const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8')); +async function verify(pack){ + const response=await fetch(`${base}/api/feed?section=images&q=${encodeURIComponent(pack.title)}`); + if(!response.ok)throw new Error(`Cannot verify feed: ${response.status}`); + const post=(await response.json()).posts.find(p=>p.slug===pack.slug); + const images=post?.assets.filter(a=>a.kind==='image'); + if(images?.length!==pack.count)throw new Error(`Wrong image count for ${pack.slug}`); + for(const image of images){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw new Error(`Missing PNG ${image.id}`);} + console.log(`Verified: /p/${pack.slug} (${images.length} stored PNGs)`); +} +for(const pack of packs){ + const existing=await fetch(`${base}/p/${pack.slug}`); + if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;} + if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`); + const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort(); + if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`); + const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description); + for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file); + const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data}); + if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`); + if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`); + console.log(`Imported: /p/${pack.slug} (${files.length} transparent PNGs)`); + await verify(pack); +} diff --git a/seed/characters/extraction.json b/seed/characters/extraction.json new file mode 100644 index 0000000..e67d79e --- /dev/null +++ b/seed/characters/extraction.json @@ -0,0 +1,106 @@ +[ + { + "file": "reference/01-basket.png", + "subject": "top-left pointing person holding basket", + "tool": "built-in imagegen", + "mode": "background-extraction" + }, + { + "file": "reference/02-phone.png", + "subject": "TOP RIGHT person in a beanie and long coat looking at a phone", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/03-coffee.png", + "subject": "upper left-middle person with glasses, a long coat and takeaway coffee, near the red figure", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/04-red-coat.png", + "subject": "red ink rear-view person in the upper middle, wearing a long coat", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/05-blue-dogs.png", + "subject": "blue ink person at upper right walking TWO small dogs; keep both dogs and leashes with this single person", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/06-dark-dog.png", + "subject": "left middle dark-haired person in a dark jacket walking one small dog to their right", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/07-pockets.png", + "subject": "middle left person in a striped beanie, hands in jacket pockets and striped trousers", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/08-blue-board.png", + "subject": "central blue ink person with a knitted beanie carrying a large rectangular portfolio", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/09-scarf-dog.png", + "subject": "middle right front-facing person with a large scarf, holding leash to a small dog on the left", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/10-elder.png", + "subject": "middle right elderly person in a beret and puffer coat pointing left and holding a dark bag", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/11-long-coat.png", + "subject": "left middle-lower side-view person in a long cream coat, striped beanie and scarf trailing right", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/12-trolley.png", + "subject": "middle-lower rear three-quarter-view ponytailed person bending beside a low trolley carrying shopping bags", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/13-bucket.png", + "subject": "lower center rear-view dark-haired person carrying a bucket in their left hand", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/14-curly.png", + "subject": "lower middle-right curly-haired person with glasses and a large checkered bag at their feet", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + }, + { + "file": "reference/15-red-walk.png", + "subject": "lower right red ink side-view person in a pompom hat, puffer jacket, backpack, walking to the left", + "tool": "built-in imagegen", + "mode": "background-extraction", + "note": "AI extraction may reconstruct strokes" + } +] \ No newline at end of file diff --git a/seed/characters/original/01-florist.png b/seed/characters/original/01-florist.png new file mode 100644 index 0000000..5f5af21 Binary files /dev/null and b/seed/characters/original/01-florist.png differ diff --git a/seed/characters/original/02-reader.png b/seed/characters/original/02-reader.png new file mode 100644 index 0000000..a3c18cb Binary files /dev/null and b/seed/characters/original/02-reader.png differ diff --git a/seed/characters/original/03-cyclist.png b/seed/characters/original/03-cyclist.png new file mode 100644 index 0000000..029dce5 Binary files /dev/null and b/seed/characters/original/03-cyclist.png differ diff --git a/seed/characters/original/04-coffee.png b/seed/characters/original/04-coffee.png new file mode 100644 index 0000000..c834905 Binary files /dev/null and b/seed/characters/original/04-coffee.png differ diff --git a/seed/characters/packs.json b/seed/characters/packs.json new file mode 100644 index 0000000..f29222d --- /dev/null +++ b/seed/characters/packs.json @@ -0,0 +1,16 @@ +[ + { + "slug": "street-people-reference", + "title": "街头小人 · 参考图提取", + "directory": "reference", + "count": 15, + "description": "从用户提供的街头人物拼贴参考图中,使用内置 imagegen 逐个人物提取、去背景。保留奶油色纸片边缘与内部填色,纸片外透明。AI 提取可能对局部笔触进行重建,并非逐像素裁剪。原图中的双人组合未收入本包。来源:用户提供的参考图,原作者及授权信息未提供。" + }, + { + "slug": "street-people-original", + "title": "街头小人 · 新生成测试", + "directory": "original", + "count": 4, + "description": "使用街头小人 v1.0.0 规范和内置 imagegen 生成的新人物:花束、阅读、推自行车、外带咖啡。参考图仅用于画风。每张一人,奶油色剪纸内部与真实透明外部背景。规范与可复用提示词:/tools/skills/street-people;机器接口:/api/skills/street-people。" + } +] diff --git a/seed/characters/prompts.json b/seed/characters/prompts.json new file mode 100644 index 0000000..af8ed24 --- /dev/null +++ b/seed/characters/prompts.json @@ -0,0 +1,26 @@ +[ + { + "file": "original/01-florist.png", + "prompt": "Use case: stylized-concept. Asset type: single transparent street-person sticker. Reference image is STYLE ONLY. Draw a NEW original individual: a person in a long coat carrying a small bouquet of flowers. Match the reference's naive observational street sketch: loose irregular felt-tip pen outline in dark muted plum, adult unidealized proportions, small head, sparse facial features, simple winter clothes, scribbled hatching on hair and trousers, charming awkward hand-drawn posture. Keep cream ivory paper inside silhouette with a narrow uneven cut-paper border. Whole body and all accessories fully visible, one subject centered, generous transparent padding. Actual RGBA transparency outside cream paper silhouette. Flat scanned paper appearance. No kraft background, no shadows, no words, no other people, no polished vector, no kawaii or anime.", + "tool": "built-in imagegen", + "transparent_background": true + }, + { + "file": "original/02-reader.png", + "prompt": "Use case: stylized-concept. Asset type: single transparent street-person sticker. Reference image is STYLE ONLY. Draw a NEW original individual: a person in a wool hat standing reading an open book, a shoulder tote bag. Match the reference's naive observational street sketch: loose irregular felt-tip pen outline in cobalt blue, adult unidealized proportions, small head, sparse facial features, simple winter clothes, scribbled hatching on hair and trousers, charming awkward hand-drawn posture. Keep cream ivory paper inside silhouette with a narrow uneven cut-paper border. Whole body and all accessories fully visible, one subject centered, generous transparent padding. Actual RGBA transparency outside cream paper silhouette. Flat scanned paper appearance. No kraft background, no shadows, no words, no other people, no polished vector, no kawaii or anime.", + "tool": "built-in imagegen", + "transparent_background": true + }, + { + "file": "original/03-cyclist.png", + "prompt": "Use case: stylized-concept. Asset type: single transparent street-person sticker. Reference image is STYLE ONLY. Draw a NEW original individual: a person wearing a scarf pushing a simple bicycle, side view. Match the reference's naive observational street sketch: loose irregular felt-tip pen outline in dark muted plum, adult unidealized proportions, small head, sparse facial features, simple winter clothes, scribbled hatching on hair and trousers, charming awkward hand-drawn posture. Keep cream ivory paper inside silhouette with a narrow uneven cut-paper border. Whole body and all accessories fully visible, one subject centered, generous transparent padding. Actual RGBA transparency outside cream paper silhouette. Flat scanned paper appearance. No kraft background, no shadows, no words, no other people, no polished vector, no kawaii or anime.", + "tool": "built-in imagegen", + "transparent_background": true + }, + { + "file": "original/04-coffee.png", + "prompt": "Use case: stylized-concept. Asset type: single transparent street-person sticker. Reference image is STYLE ONLY. Draw a NEW original individual: a person in a puffer jacket, carrying a takeaway cup and walking. Match the reference's naive observational street sketch: loose irregular felt-tip pen outline in warm coral red, adult unidealized proportions, small head, sparse facial features, simple winter clothes, scribbled hatching on hair and trousers, charming awkward hand-drawn posture. Keep cream ivory paper inside silhouette with a narrow uneven cut-paper border. Whole body and all accessories fully visible, one subject centered, generous transparent padding. Actual RGBA transparency outside cream paper silhouette. Flat scanned paper appearance. No kraft background, no shadows, no words, no other people, no polished vector, no kawaii or anime.", + "tool": "built-in imagegen", + "transparent_background": true + } +] \ No newline at end of file diff --git a/seed/characters/reference/01-basket.png b/seed/characters/reference/01-basket.png new file mode 100644 index 0000000..78367fa Binary files /dev/null and b/seed/characters/reference/01-basket.png differ diff --git a/seed/characters/reference/02-phone.png b/seed/characters/reference/02-phone.png new file mode 100644 index 0000000..4beec34 Binary files /dev/null and b/seed/characters/reference/02-phone.png differ diff --git a/seed/characters/reference/03-coffee.png b/seed/characters/reference/03-coffee.png new file mode 100644 index 0000000..080b7f4 Binary files /dev/null and b/seed/characters/reference/03-coffee.png differ diff --git a/seed/characters/reference/04-red-coat.png b/seed/characters/reference/04-red-coat.png new file mode 100644 index 0000000..ce673aa Binary files /dev/null and b/seed/characters/reference/04-red-coat.png differ diff --git a/seed/characters/reference/05-blue-dogs.png b/seed/characters/reference/05-blue-dogs.png new file mode 100644 index 0000000..8e8a9c7 Binary files /dev/null and b/seed/characters/reference/05-blue-dogs.png differ diff --git a/seed/characters/reference/06-dark-dog.png b/seed/characters/reference/06-dark-dog.png new file mode 100644 index 0000000..0cc540b Binary files /dev/null and b/seed/characters/reference/06-dark-dog.png differ diff --git a/seed/characters/reference/07-pockets.png b/seed/characters/reference/07-pockets.png new file mode 100644 index 0000000..91abfe4 Binary files /dev/null and b/seed/characters/reference/07-pockets.png differ diff --git a/seed/characters/reference/08-blue-board.png b/seed/characters/reference/08-blue-board.png new file mode 100644 index 0000000..43415dc Binary files /dev/null and b/seed/characters/reference/08-blue-board.png differ diff --git a/seed/characters/reference/09-scarf-dog.png b/seed/characters/reference/09-scarf-dog.png new file mode 100644 index 0000000..6216b65 Binary files /dev/null and b/seed/characters/reference/09-scarf-dog.png differ diff --git a/seed/characters/reference/10-elder.png b/seed/characters/reference/10-elder.png new file mode 100644 index 0000000..27ad3f4 Binary files /dev/null and b/seed/characters/reference/10-elder.png differ diff --git a/seed/characters/reference/11-long-coat.png b/seed/characters/reference/11-long-coat.png new file mode 100644 index 0000000..02a7d18 Binary files /dev/null and b/seed/characters/reference/11-long-coat.png differ diff --git a/seed/characters/reference/12-trolley.png b/seed/characters/reference/12-trolley.png new file mode 100644 index 0000000..782b45c Binary files /dev/null and b/seed/characters/reference/12-trolley.png differ diff --git a/seed/characters/reference/13-bucket.png b/seed/characters/reference/13-bucket.png new file mode 100644 index 0000000..d51394b Binary files /dev/null and b/seed/characters/reference/13-bucket.png differ diff --git a/seed/characters/reference/14-curly.png b/seed/characters/reference/14-curly.png new file mode 100644 index 0000000..5f882c0 Binary files /dev/null and b/seed/characters/reference/14-curly.png differ diff --git a/seed/characters/reference/15-red-walk.png b/seed/characters/reference/15-red-walk.png new file mode 100644 index 0000000..5bffaea Binary files /dev/null and b/seed/characters/reference/15-red-walk.png differ diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b7e0235..2681158 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -6,7 +6,7 @@ const groups=[ {label:'',items:[{href:'/',label:'首页',icon:Grid2X2}]}, {label:'素材',items:[{href:'/images',label:'图片',icon:Image},{href:'/videos',label:'视频',icon:Film},{href:'/audio',label:'声音',icon:AudioLines}]}, {label:'作品',items:[{href:'/articles',label:'图文',icon:BookOpen},{href:'/motion',label:'动态',icon:Clapperboard}]}, - {label:'工具',items:[{href:'/library/assets',label:'素材库',icon:Layers},{href:'/library/tags',label:'分类管理',icon:Tags},{href:'/tools/doodle',label:'线条工具',icon:Sparkles}]}, + {label:'工具',items:[{href:'/library/assets',label:'素材库',icon:Layers},{href:'/library/tags',label:'分类管理',icon:Tags},{href:'/tools/doodle',label:'线条工具',icon:Sparkles},{href:'/tools/skills',label:'技能',icon:BookOpen}]}, ]; export function Shell({children}:{children:ReactNode}){ const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||'')); diff --git a/src/lib/api.ts b/src/lib/api.ts index 03bc607..48736cb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -8,6 +8,7 @@ export function apiError(res: NextApiResponse,error: unknown) { if(error instanceof ZodError) return res.status(400).json({error:"请检查输入内容",issues:error.issues}); const message=error instanceof Error?error.message:""; const clientMessages:Record={ + 'Character PNG must have transparency':'请上传带真实透明背景的 PNG,透明区域 alpha 为 0,人物内部 alpha 至少为 250', 'Register the coded page before creating its post':'请先在代码中注册对应的作品页面', 'Referenced asset does not exist':'引用的素材不存在', 'A media post needs matching content':'素材包需要包含对应类型的素材', diff --git a/src/lib/character-image.ts b/src/lib/character-image.ts new file mode 100644 index 0000000..be71c91 --- /dev/null +++ b/src/lib/character-image.ts @@ -0,0 +1,11 @@ +import sharp from "sharp"; +import {streetPeople} from "./street-people"; +export async function validateCharacter(path:string) { + try { + const input=sharp(path,{limitInputPixels:streetPeople.limits.pixels}); + const meta=await input.metadata(); + if(meta.format!=="png"||!meta.hasAlpha)throw new Error("Character PNG must have transparency"); + const stats=await input.stats();const alpha=stats.channels.at(-1)!; + if(alpha.min!==0||alpha.max<250)throw new Error("Character PNG must have transparency"); + }catch{throw new Error("Character PNG must have transparency");} +} diff --git a/src/lib/street-people.ts b/src/lib/street-people.ts new file mode 100644 index 0000000..7ac0566 --- /dev/null +++ b/src/lib/street-people.ts @@ -0,0 +1,33 @@ +export const streetPeople = { + id: "street-people", version: "1.0.0", name: "街头小人", description: "松散笔触、冬日衣着与奶油色剪纸边的街头人物。", + prompt: "Draw ONE original full-body street person: {{subject}}. Naive observational felt-tip pen sketch; loose irregular dark plum, cobalt blue OR coral red lines; sparse facial features; adult proportions; simple winter clothes; scribbled hatching on hair and trousers. Ivory cream paper inside the silhouette and a narrow uneven cut-paper border. Center the entire figure and accessories, with transparent padding. True RGBA transparency outside the cutout. Hard die-cut edge, zero glow, zero feathering. No cast shadow, no background, no text, no other people, no polished vector, no anime.", + workflow: ["读取本接口的风格规范,将 {{subject}} 替换为人物动作与衣着。", "使用 agent 自己的图片生成工具,明确启用 transparent background;每张只生成一个人物。", "检查人物完整、画风和 alpha 通道;最多 16 张 PNG 通过同一 POST 请求上传,组成一个素材包。", "使用返回的 url 查看 post,assets 为可复用的素材 ID。"], + generation: { executor: "coding-agent", serverSideModel: false, reference: "/skills/street-people/reference.png", transparentBackground: true }, + upload: { method: "POST", endpoint: "/api/skills/street-people", encoding: "multipart/form-data", fields: { files: "1–16 张真实透明 PNG,每张一个完整人物;外部 alpha=0,内部 alpha≥250", title: "素材包标题(可选)", description: "提示词、生成来源和补充说明(可选)", tags: "分类 UUID 数组的 JSON(可选)", slug: "唯一小写英文网址标识(可选)" }, response: { id:"post UUID", slug:"稳定标识", url:"/p/", assets:["asset UUID"] } }, + limits: { files:16, bytesPerFile:262144000, totalBytes:1073741824, pixels:40000000 }, + errors: { 400:"PNG 格式、透明通道或表单不符合规范", 409:"slug 已存在,请查看已有 post 或更换 slug", 413:"文件数或体积超过限制", 500:"存储或服务器暂不可用" } +} as const; +export function skillMarkdown(){return `# ${streetPeople.name} + +${streetPeople.description} + +由 coding agent 使用自己的绘图工具生成,网站负责校验、存储与发布。服务器不执行模型调用。 + +## 提示词 + +${streetPeople.prompt} + +## 流程 + +${streetPeople.workflow.map((s,i)=>`${i+1}. ${s}`).join("\n")} + +## 上传 + +POST /api/skills/street-people(multipart/form-data) + +${Object.entries(streetPeople.upload.fields).map(([k,v])=>`- ${k}: ${v}`).join("\n")} + +GET /api/skills/street-people 返回完整 JSON 规范。参考图:${streetPeople.generation.reference} + +成功返回 201:id、slug、url、assets。原图提取与新生成作品必须分成两个 post,不混入同一包。 +`;} diff --git a/src/pages/api/skills/street-people.ts b/src/pages/api/skills/street-people.ts new file mode 100644 index 0000000..b157916 --- /dev/null +++ b/src/pages/api/skills/street-people.ts @@ -0,0 +1,15 @@ +import type {NextApiRequest,NextApiResponse} from "next"; +import {upload} from "../upload"; +import {streetPeople,skillMarkdown} from "@/lib/street-people"; +import {validateCharacter} from "@/lib/character-image"; +export const config={api:{bodyParser:false}}; +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + res.setHeader("Allow","GET, POST"); + if(req.method==="GET"){ + res.setHeader("Cache-Control","public, max-age=300"); + if(req.query.format==="md")return res.setHeader("Content-Type","text/markdown; charset=utf-8").status(200).send(skillMarkdown()); + return res.status(200).json(streetPeople); + } + if(req.method==="POST")return upload(req,res,{validateImage:validateCharacter,defaultTitle:"街头小人 · 新生成"}); + return res.status(405).json({error:"使用 GET 读取规范,POST 上传 PNG 素材包"}); +} diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index 1b8f9cd..5f81b62 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -10,7 +10,8 @@ import { putFile,removeObject } from "@/lib/storage"; import { probe } from "@/lib/probe"; import { sameOrigin,apiError } from "@/lib/api"; export const config={api:{bodyParser:false}}; -export default async function handler(req:NextApiRequest,res:NextApiResponse){ +export type UploadPolicy = { validateImage?: (path:string) => Promise; defaultTitle?:string }; +export async function upload(req:NextApiRequest,res:NextApiResponse,policy:UploadPolicy={}){ if(req.method!=='POST')return res.status(405).end(); const temporary:string[]=[];const uploaded:string[]=[]; try{ @@ -18,8 +19,9 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){ const form=formidable({maxFileSize:250*1024*1024,maxTotalFileSize:1024*1024*1024,maxFiles:16,allowEmptyFiles:false}); form.on('fileBegin',(_name,file)=>temporary.push(file.filepath)); const [fields,files]=await form.parse(req); - const kind=z.enum(['image','video','audio']).parse(fields.kind?.[0]); - const title=z.string().trim().min(1).max(200).parse(fields.title?.[0]); + const kind=z.enum(['image','video','audio']).parse(policy.validateImage?'image':fields.kind?.[0]); + const title=z.string().trim().min(1).max(200).parse(fields.title?.[0]||policy.defaultTitle); + const slug=z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(120).optional().parse(fields.slug?.[0]); const description=z.string().trim().max(100000).parse(fields.description?.[0]||''); const tagIds=z.array(z.uuid()).max(50).parse(JSON.parse(fields.tags?.[0]||'[]')); const items=Object.values(files).flat().filter(Boolean) as File[]; @@ -28,6 +30,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){ for(const file of items){ let mime='';let width:number|undefined,height:number|undefined,duration:number|undefined,hasAudio:boolean|null=null; if(kind==='image'){ + if(policy.validateImage)await policy.validateImage(file.filepath); const meta=await sharp(file.filepath,{limitInputPixels:100000000}).metadata(); const formats:Record={jpeg:'image/jpeg',png:'image/png',webp:'image/webp',gif:'image/gif',avif:'image/avif'}; mime=formats[meta.format||''];if(!mime)throw new Error('Unsupported image format');width=meta.width;height=meta.height; @@ -43,7 +46,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){ } const postId=randomUUID(); await transaction(async client=>{ - await client.query('INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,$3,$4)',[postId,postId,kind,title]); + await client.query('INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,$3,$4)',[postId,slug||postId,kind,title]); for(const [position,a] of records.entries()){ await client.query('INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height,duration,has_audio,processing_status) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',[a.id,kind,a.filename,a.key,a.mime,a.size,a.hash,a.width,a.height,a.duration,a.hasAudio,kind==='video'?'pending':'ready']); await client.query('INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,$3)',[postId,a.id,position]); @@ -53,7 +56,9 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){ if(description){const id=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[id,description]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[postId,id,records.length]);} for(const tag of new Set(tagIds))await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tag]); }); - uploaded.length=0;return res.status(201).json({id:postId,slug:postId,assets:records.map(a=>a.id)}); + uploaded.length=0;return res.status(201).json({id:postId,slug:slug||postId,url:`/p/${slug||postId}`,assets:records.map(a=>a.id)}); }catch(e){if(e instanceof Error && (/maxFileSize|maxTotalFileSize|maxFiles/.test(e.message)))return res.status(413).json({error:'最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB'});return apiError(res,e);} finally{await Promise.allSettled(temporary.map(p=>rm(p,{force:true})));await Promise.allSettled(uploaded.map(removeObject));} } + +export default upload; diff --git a/src/pages/tools/skills/index.tsx b/src/pages/tools/skills/index.tsx new file mode 100644 index 0000000..740b04d --- /dev/null +++ b/src/pages/tools/skills/index.tsx @@ -0,0 +1,5 @@ +import Head from "next/head"; +import Link from "next/link"; +import {ArrowUpRight} from "lucide-react"; +import {Shell} from "@/components/Shell"; +export default function Skills(){return 技能 · PROMPT

技能

把一种喜欢的画风,变成可以反复调用的创作方法。

手绘街头小人,透明剪纸素材
图片生成 · 透明 PNG

街头小人

松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。

查看文档与 Agent API →
} diff --git a/src/pages/tools/skills/street-people.tsx b/src/pages/tools/skills/street-people.tsx new file mode 100644 index 0000000..e7726d8 --- /dev/null +++ b/src/pages/tools/skills/street-people.tsx @@ -0,0 +1,14 @@ +import Head from "next/head"; +import Link from "next/link"; +import {useState} from "react"; +import {Shell} from "@/components/Shell"; +import {streetPeople} from "@/lib/street-people"; +export default function StreetPeople(){const[copied,setCopied]=useState(false);return 街头小人 · 技能 · PROMPT
← 全部技能v{streetPeople.version}
STREET PEOPLE / 01

街头,一个小小的瞬间。

用不太规整的线条,画一个正在生活的人。
奶油色剪纸边,深紫、钴蓝或珊瑚红的笔触,外部背景透明。

新生成的街头小人示例

01 / 画风约定

完整单人、成人比例、稀疏五官;不规则毡头笔线条,头发与裤子用涂抹排线。保留纸片内部的奶油色,纸片以外是真实透明通道。人物动作和随身物件可以变化。

02 / 提示词

把下方的 {'{{subject}}'} 替换成衣着与动作。每张单独生成,使用绘图工具的透明背景选项。

{streetPeople.prompt}

03 / 从生成到图包

    {streetPeople.workflow.map(s=>
  1. {s}
  2. )}

参考图提取与新生成作品分开提交,各对应一个 post。建议在说明中记录来源、提示词与工具。

04 / 调用示例

{`# BASE 为你正在访问的 PROMPT 网站地址
+curl "$BASE/api/skills/street-people"
+
+# agent 完成图片生成后上传,支持多个 files
+curl -X POST "$BASE/api/skills/street-people" \
+  -F 'title=街头小人 · 新生成' \
+  -F 'description=原创人物;使用街头小人 v1.0.0 提示词' \
+  -F 'files=@florist.png;type=image/png' \
+  -F 'files=@reader.png;type=image/png'`}

成功返回 201,包含 post 的 id、slug、url 与素材 assets ID。400 表示文件或表单不符合规范,409 表示 slug 已占用,413 表示超出上传限制。每次最多 16 张 PNG,单个 250 MB、总计 1 GB;人物 PNG 最多 4000 万像素。

} diff --git a/src/styles/globals.css b/src/styles/globals.css index 339ea48..800c87b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -6,3 +6,5 @@ @media(max-width:950px){.sidebar{width:88px;padding:26px 14px}.brand{padding:0;margin:0 auto 30px}.brand strong,.nav-item span,.nav-group>p,.import-link span,.import-link svg:last-child{display:none}.nav-item{justify-content:center;padding:16px 10px;min-height:56px;gap:0}.nav-group{margin-bottom:10px}.import-link{justify-content:center;padding:15px 10px}.main-content{margin-left:88px;padding:0 22px 25px}.detail-layout,.management-grid{grid-template-columns:1fr}.media-stage{height:65vh;min-height:400px}.doodle-layout{grid-template-columns:1fr 260px;gap:20px}.doodle-catalog{gap:16px}.activity-card{padding:20px}.asset-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.page-header p{font-size:13px}.sort-label{display:none}.feed-tabs{gap:3px}.feed-tabs a{font-size:14px;padding:11px 14px}} @media(max-width:650px){.sidebar{transform:translateX(-100%);width:210px;padding:25px 15px;transition:transform .2s}.sidebar.is-open{transform:translateX(0)}.sidebar .brand{margin:0 10px 25px}.sidebar .brand strong,.sidebar .nav-item span,.sidebar .nav-group>p,.sidebar .import-link span{display:block}.sidebar .nav-item,.sidebar .import-link{justify-content:flex-start;gap:15px;padding:13px 18px}.main-content{margin-left:0;padding:0 16px 25px}.global-topbar{height:83px;padding-left:45px;gap:12px;margin-bottom:18px}.global-search{height:46px;padding:0 13px;gap:8px}.global-search>svg{width:20px;flex-shrink:0}.global-search input{font-size:14px}.profile-mark{width:34px;height:34px;font-size:16px}.mobile-menu{display:flex;position:fixed;top:22px;left:12px;z-index:40;border:0;background:white}.nav-scrim{position:fixed;inset:0;background:#0005;z-index:25}.page-header{margin:0 0 24px;align-items:flex-start;flex-direction:column;gap:16px}.page-header h1{font-size:25px}.page-header p{font-size:13px}.activity-card{padding:18px 14px;border-radius:18px;margin-bottom:24px}.activity-top h2{font-size:15px}.activity-top h2>span{font-size:12px;margin-left:10px}.activity-year{display:none}.activity-bottom{font-size:10px}.feed-tabs a{font-size:14px;padding:10px 14px}.feed-toolbar{margin-bottom:19px}.post-cover{border-radius:14px}.masonry{gap:14px}.post-card h3{font-size:14px;margin:9px 4px 7px}.post-meta{font-size:11px;padding:0 4px}.card-tags{font-size:10px;margin:7px 4px}.empty-state p{font-size:13px}.empty-state>small{font-size:11px}.detail-top{margin-top:0;font-size:13px}.detail-layout{border-radius:18px}.media-stage{height:58vh;min-height:300px}.detail-info{padding:23px}.detail-info h1{font-size:21px}.editor-panel{padding:20px;border-radius:20px}.drop-zone{min-height:210px}.drop-zone strong{font-size:15px}.drop-zone>span:last-child{font-size:11px}.form-actions{align-items:flex-start;flex-direction:column}.form-actions button{width:100%}.kind-picker button{padding:11px 18px;font-size:14px}.asset-grid{gap:16px}.asset-preview{height:200px}.asset-tile-info{padding:11px 3px}.asset-tile-info>span{font-size:11px}.asset-tile h3{font-size:13px}.management-grid .surface{padding:23px 18px}.taxonomy-row code{font-size:11px;max-width:45%}.doodle-layout{grid-template-columns:1fr}.doodle-catalog{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.doodle-inspector{position:static}.doodle-card strong{font-size:15px}.doodle-card small{font-size:11px}} .media-stage{max-height:760px} + +.skill-card{display:grid;grid-template-columns:minmax(220px,380px) 1fr;max-width:1000px;border:1px solid #e9e5df;border-radius:24px;overflow:hidden}.skill-card>div:last-child{padding:42px}.skill-card h2{display:flex;align-items:center;gap:20px;font-size:30px;margin:22px 0 16px}.skill-card p,.skill-doc p,.skill-doc li{color:#736c63;line-height:1.9}.skill-card span:last-child{font-size:14px}.skill-art{background-color:#ede8df;background-image:linear-gradient(45deg,#e3ddd3 25%,transparent 25%),linear-gradient(-45deg,#e3ddd3 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#e3ddd3 75%),linear-gradient(-45deg,transparent 75%,#e3ddd3 75%);background-size:24px 24px;background-position:0 0,0 12px,12px -12px,-12px 0;display:grid;place-items:center;padding:28px}.skill-art img{height:340px;object-fit:contain}.skill-doc{max-width:1200px;margin:auto}.skill-hero{display:grid;grid-template-columns:1.5fr 1fr;background:#f7f3eb;border-radius:26px;overflow:hidden}.skill-hero>div:first-child{padding:45px}.skill-hero h1{font-size:clamp(28px,3vw,44px);line-height:1.4;letter-spacing:-1px;margin:24px 0}.skill-hero p{margin-bottom:25px}.skill-doc-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:45px;margin:40px 0}.skill-doc h2{font-size:21px;margin:32px 0 16px}.skill-doc pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f5f2;border:1px solid #e9e5dd;padding:24px;border-radius:16px;line-height:1.8;font-size:13px;color:#51483e}.skill-doc li{padding-left:8px;margin:12px 0}.skill-sidebar{align-self:start;position:sticky;top:112px}.skill-sidebar h2{margin-top:0}.skill-sidebar p{font-size:13px}.skill-sidebar code{display:block;font-size:12px;overflow-wrap:anywhere;margin:16px 0}.skill-sidebar .secondary-button{display:flex;margin:12px 0;padding:12px;font-size:13px}@media(max-width:800px){.skill-doc-grid{grid-template-columns:1fr;gap:10px}.skill-sidebar{position:static}.skill-hero{grid-template-columns:1fr}.skill-hero>div:first-child{padding:28px}.skill-hero .skill-art img{height:260px}.skill-card{grid-template-columns:1fr}.skill-card>div:last-child{padding:26px}} diff --git a/tests/integration.test.ts b/tests/integration.test.ts index d7c5ff6..712ddfa 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -23,7 +23,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();}; try{ let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready); - for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/library/assets','/library/tags','/tools/doodle'])assert.equal((await fetch(base+route)).status,200,route); + for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/library/assets','/library/tags','/tools/doodle','/tools/skills','/tools/skills/street-people'])assert.equal((await fetch(base+route)).status,200,route); const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white',parentId:root.id}); await assert.rejects(db().query('UPDATE tags SET parent_id=$1 WHERE id=$2',[child.id,root.id]),/cycle/); const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer(); @@ -57,6 +57,18 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video const coverResponse=await fetch(base+'/api/media/'+published.coverId);assert.equal(coverResponse.headers.get('content-type'),'image/png'); const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440); assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events'); + const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent'); + assert.equal((await fetch(base+'/api/skills/street-people?format=md')).headers.get('content-type'),'text/markdown; charset=utf-8'); + const skillUpload=async(bytes:Uint8Array,slug:string)=>{const f=new FormData();f.set('slug',slug);f.append('files',new Blob([new Uint8Array(bytes)],{type:'image/png'}),'person.png');return fetch(base+'/api/skills/street-people',{method:'POST',body:f});}; + const skillSlug='skill-'+randomUUID(); + assert.equal((await skillUpload(a,skillSlug)).status,400,'Opaque PNG rejected'); + const opaqueAlpha=await sharp({create:{width:64,height:64,channels:4,background:'#fff'}}).png().toBuffer(); + assert.equal((await skillUpload(opaqueAlpha,skillSlug)).status,400,'Unused alpha channel rejected'); + const transparent=await sharp({create:{width:64,height:64,channels:4,background:'#0000'}}).composite([{input:await sharp({create:{width:32,height:32,channels:4,background:'#faf4e5'}}).png().toBuffer(),left:16,top:16}]).png().toBuffer(); + const created=await skillUpload(transparent,skillSlug);assert.equal(created.status,201,await created.clone().text());const character=await created.json();assert.equal(character.slug,skillSlug);assert.equal(character.url,'/p/'+skillSlug); + assert.equal((await skillUpload(transparent,skillSlug)).status,409,'Stable slug prevents duplicate posts'); + const stored=await fetch(base+'/api/media/'+character.assets[0]);assert.deepEqual(Buffer.from(await stored.arrayBuffer()),transparent,'PNG alpha and original bytes preserved in S3'); + assert.equal((await fetch(base+character.url)).status,200); }finally{ for(const child of [worker,server])if(child&&child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;} await rm(dir,{recursive:true,force:true});await db().end();