Separate authored canvases from post snapshots and unify linked skill documentation
PROMPT Docker CI and deployment / verify-and-deploy (push) Failing after 24m58s
PROMPT Docker CI and deployment / verify-and-deploy (push) Failing after 24m58s
This commit is contained in:
+2
-2
@@ -2,7 +2,7 @@ FROM node:24-bookworm-slim AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
@@ -23,7 +23,7 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
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/container-health.mjs scripts/wait-snapshots.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
|
||||
|
||||
@@ -38,8 +38,6 @@ docker compose --env-file .env up -d --wait web worker
|
||||
- 上述五类路径可以附加标签树路径,如 `/images/style/white-line`;父分类会包含后代分类的内容。
|
||||
- `/p/<slug>`:素材包的稳定详情地址,不随标签改变。多图切换、多视频及其关联音轨、多声音均可展示。
|
||||
- `/library/import`:导入同一类型的一组文件和说明,自动收为一份收藏。支持最多 16 个文件,单个 250 MB,总计 1 GB。
|
||||
- `/library/assets`:查看每份独立素材及被引用次数,复制 ID 以复用。
|
||||
- `/library/tags`:维护严格单父级分类树;同名 slug 只允许出现在不同分支。
|
||||
- `/tools/doodle`:手绘线条造型参考,可选色、复制和下载透明 SVG。不是 AI 图片编辑器。
|
||||
|
||||
素材库不插入演示或占位帖子。空分类显示简洁的空状态;空素材包不进入任何瀑布流。分页游标由时间与 UUID 组成,重复时间戳下仍稳定。
|
||||
@@ -115,3 +113,12 @@ PROMPT_IMAGE=prompt:local PROMPT_TEST_IMAGE=prompt:tests bash scripts/integratio
|
||||
把 `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 执行,可能重建细节,并非逐像素无损裁剪。
|
||||
|
||||
|
||||
## 网页原稿、截图与技能文档
|
||||
|
||||
图文笔记中的每张图片拥有独立网页:`/p/<slug>/pages/1` 至 `/pages/8`。笔记页展示真实服务器截图,可刷新一张或全部,并单独打开 HTML 原稿。截图由 Docker worker 内 Chromium 生成并存入私有 S3,发布会等待当版全部 24 张截图完成。数据与任务结构见 `db/002_page_snapshots.sql`,详细行为见 `docs/learning-series.md`。
|
||||
|
||||
工具栏首项为技能;素材库与分类管理页面已移除,已有素材、标签、导入和数据 API 保留。技能使用瀑布卡片,首页也混排展示。
|
||||
|
||||
交给 agent 的入口为 `/skills/street-people/SKILL.md`,后续子文档为 `style.md`、`upload.md`、`checks.md`;纯文本只通过链接引用图片、文档和 API。人类阅读入口 `/tools/skills/street-people` 和对应子页面使用相同文档数据。右侧资源面板分类收录全部文档、图片和 API,支持预览与打开;不会执行 POST,也不展示与技能流程无关的测试图包。旧 JSON API 和 `?format=md` 兼容保留,后者返回相同入口文档。
|
||||
|
||||
@@ -9,6 +9,8 @@ x-app-environment: &app-environment
|
||||
RELEASE_SHA: ${RELEASE_SHA:-development}
|
||||
FFMPEG_PATH: /usr/bin/ffmpeg
|
||||
FFPROBE_PATH: /usr/bin/ffprobe
|
||||
CHROMIUM_PATH: /usr/bin/chromium
|
||||
RENDER_ORIGIN: http://web:3000
|
||||
|
||||
x-app: &app
|
||||
image: ${PROMPT_IMAGE:-prompt:local}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
CREATE TABLE page_snapshots(post_id uuid NOT NULL REFERENCES posts(id),page_index integer NOT NULL CHECK(page_index BETWEEN 1 AND 8),asset_id uuid NOT NULL REFERENCES assets(id),rendered_at timestamptz NOT NULL DEFAULT now(),revision text NOT NULL,PRIMARY KEY(post_id,page_index));
|
||||
CREATE TABLE render_jobs(post_id uuid PRIMARY KEY REFERENCES posts(id),status text NOT NULL CHECK(status IN ('pending','processing','ready','failed')),revision text NOT NULL,attempts integer NOT NULL DEFAULT 0,target_page integer CHECK(target_page BETWEEN 1 AND 8),locked_at timestamptz,error text);
|
||||
INSERT INTO schema_migrations(version) VALUES('002_page_snapshots');
|
||||
COMMIT;
|
||||
@@ -23,3 +23,12 @@ Docker 集成测试会重复执行发布,检查三个作品、共用封面的
|
||||
## 验收约定
|
||||
|
||||
先推送 Git,再等 CI/CD 成功,最后直接访问服务器 http://8.211.154.134:18131/ 验收。不要启动本地网页预览,不把 localhost 作为展示或交付入口。
|
||||
|
||||
|
||||
## 独立网页与真实截图(新版)
|
||||
|
||||
每篇笔记仍使用 `/p/<slug>`;每一张图的独立 HTML/CSS/TypeScript 原稿使用 `/p/<slug>/pages/1` 至 `/pages/8`。这些原稿没有网站导航与笔记说明,固定为 1080×1440;第一张封面也是可渲染的 SVG DOM,而非直接嵌入原来的 PNG。
|
||||
|
||||
笔记页展示 `page_snapshots` 引用的 S3 PNG,可打开原稿、刷新当前一张或更新全部。POST `/api/posts/<slug>/snapshots` 接收可选 `{page: 1..8}`,GET 返回状态与截图列表。服务只允许代码登记的页面,不接受任意网址。后台 worker 使用 Chromium 截图,等待字体就绪,并检查内容没有压住页脚;网络请求只允许内部 Web 服务。完整一轮成功后在事务中更新引用,失败保留旧图。图片按摘要去重,三篇封面继续共用素材。
|
||||
|
||||
部署使用 Docker 内 Chromium 与中文字体,等待 24 张当前版本截图全部完成才确认发布。没有本地浏览器预览。
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["playwright-core"],
|
||||
async rewrites(){return [{source:"/skills/street-people/:doc.md",destination:"/api/skill-docs/street-people/:doc"}]},
|
||||
output: "standalone",
|
||||
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
|
||||
Generated
+13
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "1.61.0",
|
||||
"next": "16.4.2",
|
||||
"pg": "8.23.1",
|
||||
"playwright-core": "1.56.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"sharp": "0.35.5",
|
||||
@@ -2567,6 +2568,18 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
|
||||
+3
-2
@@ -7,10 +7,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
|
||||
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts",
|
||||
"test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts",
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||
@@ -23,6 +23,7 @@
|
||||
"lucide-react": "1.61.0",
|
||||
"next": "16.4.2",
|
||||
"pg": "8.23.1",
|
||||
"playwright-core": "1.56.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"sharp": "0.35.5",
|
||||
|
||||
@@ -74,6 +74,7 @@ if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then system
|
||||
if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi
|
||||
compose up -d --no-build --wait --wait-timeout 180 web worker
|
||||
compose exec -T web node container-health.mjs
|
||||
compose exec -T web node wait-snapshots.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'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];
|
||||
for(let attempt=0;attempt<120;attempt++){
|
||||
const states=await Promise.all(slugs.map(async slug=>{const r=await fetch('http://127.0.0.1:3000/api/posts/'+slug+'/snapshots');if(!r.ok)throw Error('Screenshot state unavailable');return r.json()}));
|
||||
const failed=states.find(s=>s.job?.status==='failed');if(failed)throw Error(failed.job.error||'Screenshot generation failed');
|
||||
if(states.every(s=>s.job?.status==='ready'&&s.job.revision===process.env.RELEASE_SHA&&s.pages.length===8&&s.pages.every(p=>p.revision===process.env.RELEASE_SHA))){console.log('Verified 24 current server-rendered page snapshots');process.exit(0)}
|
||||
await new Promise(r=>setTimeout(r,2000));
|
||||
}
|
||||
throw Error('Screenshot publication timed out');
|
||||
@@ -0,0 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import {useEffect,useState} from "react";
|
||||
import {ChevronLeft,ChevronRight,RefreshCw,ExternalLink} from "lucide-react";
|
||||
import {mediaUrl} from "@/lib/model";
|
||||
type State={pages:{page_index:number;asset_id:string;rendered_at:string}[];job:{status:string;error:string|null}|null};
|
||||
export function ArticleGallery({slug}:{slug:string}){
|
||||
const [state,setState]=useState<State>({pages:[],job:null});const[index,setIndex]=useState(1);const[error,setError]=useState('');const[requesting,setRequesting]=useState(false);
|
||||
const pending=state.job?.status==='pending'||state.job?.status==='processing';
|
||||
useEffect(()=>{let live=true;const load=async()=>{try{const r=await fetch(`/api/posts/${slug}/snapshots`);if(!r.ok)throw Error();const s=await r.json();if(live){setState(s);setError('');}}catch{if(live)setError('截图状态读取失败,请重试。');}};void load();const timer=setInterval(()=>void load(),3000);return()=>{live=false;clearInterval(timer)};},[slug]);
|
||||
async function refresh(all=false){setRequesting(true);setError('');try{const r=await fetch(`/api/posts/${slug}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(all?{}:{page:index})});if(!r.ok)throw Error();setState(s=>({...s,job:{status:'pending',error:null}}));}catch{setError('刷新没有完成,请重试。')}finally{setRequesting(false)}}
|
||||
const selected=state.pages.find(p=>p.page_index===index);const url=`/p/${slug}/pages/${index}`;
|
||||
return <div className="snapshot-reader"><header><div><b>这篇笔记 · 8 张图</b><p>{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}</p></div><button className="secondary-button" disabled={pending||requesting} onClick={()=>void refresh()}><RefreshCw size={15}/>刷新这张截图</button><button className="text-link" disabled={pending||requesting} onClick={()=>void refresh(true)}>更新全部</button></header>{error&&<p role="alert">{error}</p>}{state.job?.status==='failed'&&<p role="alert">截图未完成,保留上次版本。可以重新刷新。</p>}<div className="snapshot-stage">{selected?<a href={url} target="_blank" rel="noreferrer"><img src={mediaUrl(selected.asset_id)} alt={`笔记第 ${index} 张截图`} width={1080} height={1440}/></a>:<div className="snapshot-placeholder">这张图还没有截图。<Link href={url} target="_blank">先打开网页原稿 ↗</Link></div>}</div><nav><button aria-label="上一张" disabled={index===1} onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><span>{index} / 8</span><button aria-label="下一张" disabled={index===8} onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><Link href={url} target="_blank"><ExternalLink size={15}/>打开这张图的网页</Link></nav><div className="snapshot-thumbs">{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return <button aria-label={`查看第 ${i+1} 张截图`} aria-pressed={index===i+1} onClick={()=>setIndex(i+1)} key={i}>{p?<img src={mediaUrl(p.asset_id)} alt=""/>:<span>{i+1}</span>}</button>})}</div></div>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import {SkillCard} from "./SkillCard";
|
||||
import {skillCatalog} from "@/content/skills/catalog";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useEffect,useRef,useState } from "react";
|
||||
@@ -15,13 +17,14 @@ export function PostCard({post}:{post:Post}){
|
||||
{count>1&&<span className="cover-count"><Layers size={12}/>{count}</span>}{video&&<span className="cover-badge">{video.has_audio?'有声音':'无声音'}</span>}
|
||||
</div><h3>{post.title}</h3><div className="post-meta"><span><i className={`type-dot ${post.kind}`}/>{label}</span><time>{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}</time></div>{post.tags.length>0&&<div className="card-tags">{post.tags.slice(0,2).map(t=><span key={t.id}>#{t.name}</span>)}</div>}</Link>;
|
||||
}
|
||||
function Masonry({posts}:{posts:Post[]}){
|
||||
function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:boolean;q?:string}){
|
||||
const element=useRef<HTMLDivElement>(null);const [width,setWidth]=useState(1100);
|
||||
useEffect(()=>{if(!element.current)return;const observer=new ResizeObserver(entries=>setWidth(entries[0].contentRect.width));observer.observe(element.current);return ()=>observer.disconnect();},[]);
|
||||
const count=width>1250?5:width>1000?4:width>720?3:2;
|
||||
const columns:Post[][]=Array.from({length:count},()=>[]);const heights=Array(count).fill(0);const cardWidth=(width-22*(count-1))/count;
|
||||
for(const post of posts){const image=post.assets.find(a=>a.kind==='image');const ratio=image?.width&&image.height?image.height/image.width:post.kind==='audio'?1:1.25;const index=heights.indexOf(Math.min(...heights));columns[index].push(post);heights[index]+=Math.min(cardWidth*ratio,460)+90+Math.ceil(post.title.length/15)*20;}
|
||||
return <div ref={element} className="masonry" style={{gridTemplateColumns:`repeat(${count}, minmax(0,1fr))`}}>{columns.map((items,i)=><div className="masonry-column" key={i}>{items.map(p=><PostCard post={p} key={p.id}/>)}</div>)}</div>;
|
||||
type Item={post?:Post;skill?:typeof skillCatalog[number];date:string};const columns:Item[][]=Array.from({length:count},()=>[]);const items:Item[]=[...posts.map(post=>({post,date:post.created_at})),...(includeSkills?skillCatalog.filter(s=>(s.title+s.description+s.category).toLowerCase().includes(q.toLowerCase())).map(skill=>({skill,date:skill.created_at})):[])].sort((a,b)=>b.date.localeCompare(a.date));const heights=Array(count).fill(0);const cardWidth=(width-22*(count-1))/count;
|
||||
for(const item of items){const image=item.post?.assets.find(a=>a.kind==='image');const ratio=item.skill?1.25:image?.width&&image.height?image.height/image.width:item.post?.kind==='audio'?1:1.25;const index=heights.indexOf(Math.min(...heights));columns[index].push(item);heights[index]+=Math.min(cardWidth*ratio,460)+(item.skill?170:120);}
|
||||
|
||||
return <div ref={element} className="masonry" style={{gridTemplateColumns:`repeat(${count}, minmax(0,1fr))`}}>{columns.map((items,i)=><div className="masonry-column" key={i}>{items.map(item=>item.skill?<SkillCard skill={item.skill} key={item.skill.id}/>:<PostCard post={item.post!} key={item.post!.id}/>)}</div>)}</div>;
|
||||
}
|
||||
export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:string;tagPath?:string}){
|
||||
const router=useRouter();const [posts,setPosts]=useState(initial.posts);const [cursor,setCursor]=useState(initial.nextCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const generation=useRef(0);
|
||||
@@ -33,7 +36,7 @@ export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:
|
||||
{!section&&<Heatmap data={initial.contributions}/>}
|
||||
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
|
||||
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
|
||||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:posts.length?<Masonry posts={posts}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||||
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
|
||||
<footer className="page-footer">PROMPT <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
|
||||
</Shell>;
|
||||
|
||||
@@ -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},{href:'/tools/skills',label:'技能',icon:BookOpen}]},
|
||||
{label:'工具',items:[{href:'/tools/skills',label:'技能',icon:BookOpen},{href:'/tools/doodle',label:'线条工具',icon:Sparkles}]},
|
||||
];
|
||||
export function Shell({children}:{children:ReactNode}){
|
||||
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import Link from 'next/link';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return <Link href={skill.href} className="post-card skill-pin"><div className="post-cover skill-pin-cover"><img src={skill.image} alt={skill.alt}/></div><span className="skill-pin-category">{skill.category}</span><h3>{skill.title}</h3><p>{skill.description}</p><span className="skill-pin-link">查看文档与 Agent 入口 ↗</span></Link>}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {useState,useEffect,useRef} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {streetPeople} from "@/lib/street-people";
|
||||
import {X,PanelRight,Copy,ExternalLink} from 'lucide-react';
|
||||
import {Shell} from './Shell';
|
||||
import {skillDocuments,skillResources,documentMarkdown,type Resource} from '@/content/skills/catalog';
|
||||
export function SkillDocs({documentId='index'}:{documentId?:string}){
|
||||
const router=useRouter();const doc=skillDocuments.find(d=>d.id===documentId)!;const [open,setOpen]=useState(false);const[selected,setSelected]=useState<Resource|null>(null);const[copied,setCopied]=useState(false);const panel=useRef<HTMLElement>(null);
|
||||
useEffect(()=>{setOpen(window.innerWidth>760)},[]);
|
||||
useEffect(()=>{setSelected(null);setCopied(false)},[documentId]);
|
||||
useEffect(()=>{if(!open)return;const handler=(e:KeyboardEvent)=>{if(e.key==='Escape')setOpen(false)};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler)},[open]);
|
||||
const resource=(id:string)=>skillResources.find(r=>r.id===id)!;
|
||||
return <Shell><Head><title>{`${doc.title} · 技能文档 · PROMPT`}</title></Head><div className="docs-top"><Link href="/tools/skills">← 全部技能</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {skillResources.length}</button></div><div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body"><div className="docs-eyebrow">街头小人 / SKILL DOCUMENTS</div><h1>{doc.title}</h1><div className="docs-actions"><button onClick={async()=>{try{const value=location.origin+'/skills/street-people/SKILL.md';if(navigator.clipboard)await navigator.clipboard.writeText(value);else{const input=document.createElement('textarea');input.value=value;document.body.appendChild(input);input.select();const ok=document.execCommand('copy');input.remove();if(!ok)throw Error('Copy unavailable');}setCopied(true)}catch{setCopied(false)}}}><Copy size={14}/>{copied?'已复制入口文档':'复制入口文档给 Agent'}</button><a href={resource(documentId).href} target="_blank" rel="noreferrer">纯文本版本 ↗</a></div><p className="docs-hint">网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。</p>{doc.blocks.map((b,i)=>b.type==='heading'?<h2 key={i}>{b.text}</h2>:b.type==='code'?<pre key={i}>{b.text}</pre>:b.type==='links'?<div className="docs-inline-links" key={i}>{b.refs!.map(id=>{const r=resource(id);return <a href={r.human||r.href} key={id} onClick={e=>{e.preventDefault();setSelected(r);setOpen(true);panel.current?.focus()}}>{r.kind==='document'?'文档':r.kind==='image'?'图片':r.method} · {r.title}<ExternalLink size={12}/></a>})}</div>:<p key={i}>{b.text}</p>)}</article>{open&&<aside className="docs-sources" ref={panel} tabIndex={-1} aria-label="技能所有文档与来源"><header><div><b>文档与来源</b><small>当前技能的完整资源索引</small></div><button aria-label="关闭文档与来源" onClick={()=>setOpen(false)}><X size={18}/></button></header>{(['document','image','api'] as const).map(kind=><section key={kind}><h3>{kind==='document'?'文档':kind==='image'?'参考与示意图':'API 接口'}</h3>{skillResources.filter(r=>r.kind===kind).map(r=><button key={r.id} className={selected?.id===r.id?'selected':''} onClick={()=>setSelected(r)}><span>{r.method|| (r.kind==='image'?'IMG':'MD')}</span><div>{r.title}<small>{r.href}</small></div></button>)}</section>)}{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={selected.href} alt={selected.title}/>:selected.kind==='document'?<pre>{documentMarkdown(selected.id)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.method==='POST'?streetPeople.upload:streetPeople,null,2)}</pre><p>{selected.method==='POST'?'上传接口。阅读上传文档后,由 Agent 使用 multipart/form-data 提交文件。此处仅展示说明,不会发送请求。':'返回 JSON 规范。入口文档负责引导流程,JSON 用于查阅接口结构。'}</p></>}<div className="docs-resource-actions">{selected.human&&<Link href={selected.human}>打开文档网页 ↗</Link>}{selected.method!=='POST'&&<a href={selected.href} target="_blank" rel="noreferrer">{selected.kind==='document'?'打开纯文本':'打开资源'} ↗</a>}</div></div>}</aside>}</div></Shell>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { coverSvg } from "./cover";
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronLeft, ChevronRight, List, BookOpen, Check, ArrowDown } from "lucide-react";
|
||||
import { chapters, seriesCover, sourceChecked, type Chapter, type Skill } from "./data";
|
||||
@@ -16,15 +17,16 @@ function SkillCard({skill,index}:{skill:Skill;index:number}) {
|
||||
return <article className="learning-skill"><div className="learning-skill-heading"><span className="learning-number">{String(index+1).padStart(2,"0")}</span><div><span className="learning-category">{skill.category}</span><h3>{skill.title}</h3></div></div><a className="learning-skill-name" href={skill.source} target="_blank" rel="noreferrer">{skill.name}<ArrowUpRight size={13}/></a><div className="learning-io"><p><b>给它</b><span>{skill.input}</span></p><p><b>得到</b><span>{skill.output}</span></p></div><Sample skill={skill} index={index}/><div className="learning-prompt"><span>可以这样问</span><p>“{skill.prompt}”</p></div><p className="learning-caveat">{skill.note}</p></article>;
|
||||
}
|
||||
const labels=["系列封面","本篇索引","01—02","03—04","05—06","07—08","09—10","使用路线"];
|
||||
export function LearningSeries({chapterKey}:{chapterKey:string}) {
|
||||
export function LearningSeries({chapterKey,pageNumber,standalone=false}:{chapterKey:string;pageNumber?:number;standalone?:boolean}) {
|
||||
const chapter=chapters.find(c=>c.key===chapterKey)!;
|
||||
const [page,setPage]=useState(0);const [continuous,setContinuous]=useState(false);
|
||||
const [current,setCurrent]=useState(0);const page=pageNumber??current;const setPage=(value:number|((p:number)=>number))=>{const next=typeof value==='function'?value(page):value;if(standalone)location.href=`/p/${chapter.slug}/pages/${next+1}`;else setCurrent(next);};const [continuous,setContinuous]=useState(false);
|
||||
const boards:ReactNode[]=[
|
||||
<div className="learning-cover" key="cover"><img src={seriesCover} alt="学习资料,自己做。笔记整理、练习试卷、英语学习,30 个 AI Skills 收藏清单。" width={1080} height={1440} fetchPriority="high"/></div>,
|
||||
<div className="learning-cover learning-html-cover" key="cover" dangerouslySetInnerHTML={{__html:coverSvg}}/>,
|
||||
<Sheet chapter={chapter} page={1} key="index"><div className="learning-intro"><span className="learning-chapter">第 {chapter.number} 辑 · 10 个 Skills</span><h2>{chapter.headline[0]}<br/><mark>{chapter.headline[1]}</mark></h2><p>{chapter.subtitle}</p><span className="learning-audience">{chapter.audience}</span></div><div className="learning-index">{chapter.skills.map((skill,i)=><button key={skill.name} onClick={()=>{setContinuous(false);setPage(2+Math.floor(i/2));}}><span>{String(i+1).padStart(2,"0")}</span><div><strong>{skill.title}</strong><small>{skill.name}</small></div><ArrowUpRight size={18}/></button>)}</div><p className="learning-index-note"><BookOpen size={16}/>按需求挑选,不必一次装完。点击条目查看介绍。</p></Sheet>,
|
||||
...Array.from({length:5},(_,i)=><Sheet chapter={chapter} page={i+2} key={`skills-${i}`} className="learning-pair"><header className="learning-pair-title"><span>把工具用在具体的事情上</span><h2>{["先整理,再开始。","把资料做成自己的。","让学习多一条路径。","把看过变成理解。","留下一份能复用的资料。"][i]}</h2></header>{chapter.skills.slice(i*2,i*2+2).map((skill,j)=><SkillCard key={skill.name} skill={skill} index={i*2+j}/>)}</Sheet>),
|
||||
<Sheet chapter={chapter} page={7} key="workflow"><div className="learning-ending"><span className="learning-chapter">收藏之后,试着做一份。</span><h2>先选一个需求,<br/><mark>再串成工作流。</mark></h2><p>不求用遍所有工具,先完成一份自己的资料。</p></div><ol className="learning-workflow">{chapter.workflow.map((line,i)=><li key={line}><b>{i+1}</b><span>{line}</span>{i<2&&<ArrowDown size={19}/>}</li>)}</ol><div className="learning-source-box"><h3>10 个来源,放在这里了。<ArrowUpRight size={20}/></h3><div>{chapter.skills.map((skill,i)=><a key={skill.name} href={skill.source} target="_blank" rel="noreferrer"><span>{String(i+1).padStart(2,"0")}</span>{skill.name}<ArrowUpRight size={12}/></a>)}</div><p>依据 GitHub 项目说明整理 · 核对于 {sourceChecked}<br/>尚未逐项安装实测;文中小样为排版示意。</p></div><div className="learning-series-reminder"><b>同系列还有</b>{chapters.filter(c=>c.key!==chapter.key).map(c=><Link key={c.key} href={`/p/${c.slug}`}>{c.number} / {c.subtitle}<ArrowUpRight size={14}/></Link>)}</div></Sheet>,
|
||||
];
|
||||
if(standalone)return <main className="learning-standalone w-[1080px] h-[1440px] overflow-hidden" style={{"--learning-accent":chapter.color} as CSSProperties}><div className="learning-boards" data-render-page>{boards[page]}</div></main>;
|
||||
return <div className="learning-reader" style={{"--learning-accent":chapter.color} as CSSProperties}>
|
||||
<div className="learning-reader-bar"><div><span className="learning-reader-badge">系列 {chapter.number} / 03</span><strong>{chapter.subtitle}</strong></div><button onClick={()=>setContinuous(v=>!v)} aria-pressed={continuous}><List size={16}/>{continuous?"分页阅读":"连续阅读"}</button></div>
|
||||
<div className={`learning-boards ${continuous?"is-continuous":""}`} onKeyDown={e=>{if(e.target!==e.currentTarget||continuous)return;if(e.key==="ArrowRight"){e.preventDefault();setPage(p=>Math.min(7,p+1));}if(e.key==="ArrowLeft"){e.preventDefault();setPage(p=>Math.max(0,p-1));}}} tabIndex={0} aria-label="图文画板,可使用左右方向键翻页">{continuous?boards:boards[page]}</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
import {streetPeople} from "@/lib/street-people";
|
||||
export type Resource={id:string;kind:'document'|'image'|'api';title:string;href:string;human?:string;method?:string};
|
||||
export type Block={type:'paragraph'|'heading'|'code'|'links';text?:string;refs?:string[]};
|
||||
export type SkillDocument={id:string;title:string;blocks:Block[]};
|
||||
export const skillResources:Resource[]=[
|
||||
{id:'index',kind:'document',title:'入口文档 · SKILL.md',href:'/skills/street-people/SKILL.md',human:'/tools/skills/street-people'},
|
||||
{id:'style',kind:'document',title:'画风与生成提示词',href:'/skills/street-people/style.md',human:'/tools/skills/street-people/style'},
|
||||
{id:'upload',kind:'document',title:'上传与返回结果',href:'/skills/street-people/upload.md',human:'/tools/skills/street-people/upload'},
|
||||
{id:'checks',kind:'document',title:'交付检查清单',href:'/skills/street-people/checks.md',human:'/tools/skills/street-people/checks'},
|
||||
{id:'reference',kind:'image',title:'画风参考图',href:'/skills/street-people/reference.png'},
|
||||
{id:'sample',kind:'image',title:'透明人物示意图',href:'/skills/street-people/sample.png'},
|
||||
{id:'spec',kind:'api',title:'机器可读接口规范',href:'/api/skills/street-people',method:'GET'},
|
||||
{id:'submit',kind:'api',title:'提交透明 PNG 图包',href:'/api/skills/street-people',method:'POST'},
|
||||
];
|
||||
export const skillDocuments:SkillDocument[]=[
|
||||
{id:'index',title:'街头小人',blocks:[
|
||||
{type:'paragraph',text:'用松散的线条、冬天的衣着与奶油色剪纸边,留住街上的一个瞬间。此文档是交给 coding agent 的入口;先阅读说明,再按链接选择需要的子文档与接口。'},
|
||||
{type:'heading',text:'从这里开始'},
|
||||
{type:'paragraph',text:'绘图由 agent 自己的图像工具执行。网站负责提供风格规范、检查透明 PNG,并存储发布素材包;不在网站配置模型密钥。'},
|
||||
{type:'links',refs:['style','reference','sample']},
|
||||
{type:'heading',text:'生成与交付'},
|
||||
{type:'paragraph',text:'每张图只包含一个完整人物及其随身物件。明确启用透明背景,检查外部透明、内部纸张与完整轮廓,再按上传文档提交。多个文件组成一篇素材笔记,同一图片仍可被其他笔记引用。'},
|
||||
{type:'links',refs:['upload','checks']},
|
||||
{type:'paragraph',text:'所有文档、图片与 API 的完整索引见下方。入口与子文档均为纯文本 Markdown,图片始终以链接提供。'},
|
||||
{type:'links',refs:skillResources.map(r=>r.id)},
|
||||
]},
|
||||
{id:'style',title:'画风与生成提示词',blocks:[{type:'paragraph',text:'完整单人、成人比例、稀疏五官;不规则毡头笔线条,头发与裤子用涂抹排线。保留纸片内部的奶油色,纸片以外是真实透明通道。'}, {type:'links',refs:['reference','sample']},{type:'heading',text:'生成提示词'},{type:'paragraph',text:'将 {{subject}} 替换成衣着与动作。每张单独生成,使用绘图工具的透明背景选项。'},{type:'code',text:streetPeople.prompt},{type:'links',refs:['checks','upload','index']}]},
|
||||
{id:'upload',title:'上传与返回结果',blocks:[{type:'paragraph',text:'先由 agent 生成 PNG,再以 multipart/form-data 提交。API 不负责绘图。'}, {type:'links',refs:['submit','spec']},{type:'code',text:`POST /api/skills/street-people\nContent-Type: multipart/form-data\n\n${Object.entries(streetPeople.upload.fields).map(([k,v])=>k+': '+v).join('\n')}\n\n201: { id, slug, url, assets }\n400: 文件或表单不符合规范\n409: slug 已存在\n413: 超出上传限制\n500: 服务器或存储错误`},{type:'heading',text:'调用示例'},{type:'code',text:`curl -X POST "$BASE/api/skills/street-people" \\\n -F 'title=街头小人 · 冬日片段' \\\n -F 'slug=winter-street-people' \\\n -F 'description=人物动作、提示词与生成来源' \\\n -F 'files=@person.png;type=image/png'`},{type:'paragraph',text:'最多 16 张,单文件 250 MB,总计 1 GB,每张最多 4000 万像素。重复 slug 返回 409,不会覆盖已有笔记。使用返回的 url 查看结果。'},{type:'links',refs:['checks','index']}]},
|
||||
{id:'checks',title:'交付检查清单',blocks:[{type:'paragraph',text:'逐张检查完整人物、纸片边缘、衣着与动作。外部需要真实透明像素 alpha=0,内部需要接近不透明像素 alpha≥250。仅增加 alpha 通道而没有透明背景的 PNG 会被拒绝。'}, {type:'paragraph',text:'检查无截肢、无额外人物、无文字、无背景和外部阴影。记录生成来源与提示词;来源不同的图包分开提交。服务器的像素检查不能代替 agent 对画风和内容的检查。'}, {type:'links',refs:['style','sample','upload','index']}]},
|
||||
];
|
||||
export const skillCatalog=[{id:'street-people',title:'街头小人',category:'图片生成 · 透明 PNG',description:'松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。',image:'/skills/street-people/sample.png',alt:'手绘街头小人,透明剪纸素材',href:'/tools/skills/street-people',created_at:'2026-10-22T17:29:47Z'}];
|
||||
export function documentMarkdown(id:string,origin='http://8.211.154.134:18131'){
|
||||
const doc=skillDocuments.find(d=>d.id===id);if(!doc)return null;
|
||||
return `# ${doc.title}\n\n`+doc.blocks.map(b=>b.type==='heading'?`## ${b.text}`:b.type==='code'?`\x60\x60\x60\n${b.text}\n\x60\x60\x60`:b.type==='links'?b.refs!.map(id=>{const r=skillResources.find(r=>r.id===id)!;return `- [${r.title}](${new URL(r.href,origin).href})${r.method?' — '+r.method:''}`}).join('\n'):b.text).join('\n\n')+'\n';
|
||||
}
|
||||
@@ -29,15 +29,16 @@ export async function publishLearningSeries() {
|
||||
const child=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId(`tag-${chapter.key}`),root,chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
|
||||
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5)
|
||||
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,cover_asset_id=excluded.cover_asset_id,updated_at=now()
|
||||
WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId]);
|
||||
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,cover_asset_id=COALESCE((SELECT asset_id FROM page_snapshots WHERE post_id=posts.id AND page_index=1),excluded.cover_asset_id),updated_at=now()
|
||||
WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId]);
|
||||
if(!result.rows[0])throw new Error(`Publication slug is already used by another post: ${chapter.slug}`);
|
||||
const postId=result.rows[0].id;const captionId=stableId(`caption-${chapter.key}`);
|
||||
const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n${chapter.subtitle}。三篇共用系列封面,每篇 8 页,介绍 10 项工具。\n\n${chapter.skills.map((skill,i)=>`${String(i+1).padStart(2,"0")} ${skill.name} — ${skill.title}`).join("\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。内页小样为排版示意;可在末页访问全部来源。`;
|
||||
await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2) ON CONFLICT(id) DO UPDATE SET body=excluded.body",[captionId,caption]);
|
||||
await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'content'),($1,$3,1,'caption')
|
||||
ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role=excluded.role`,[postId,coverId,captionId]);
|
||||
ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role=excluded.role`,[postId,result.rows[0].cover_asset_id,captionId]);
|
||||
await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",[postId,child]);
|
||||
await client.query(`INSERT INTO render_jobs(post_id,status,revision) VALUES($1,'pending',$2) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,attempts=0,target_page=NULL,error=NULL WHERE render_jobs.revision IS DISTINCT FROM excluded.revision`,[postId,process.env.RELEASE_SHA||'development']);
|
||||
published.push(chapter.slug);
|
||||
}
|
||||
return {published,coverId};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {chromium} from "playwright-core";
|
||||
import {createHash,randomUUID} from "node:crypto";
|
||||
import {mkdtemp,rm} from "node:fs/promises";
|
||||
import {tmpdir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {db,transaction} from "./db";
|
||||
import {putFile} from "./storage";
|
||||
import {chapters} from "../content/learning-skills/data";
|
||||
export async function snapshotState(slug:string){
|
||||
const post=(await db().query('SELECT id,page_key FROM posts WHERE slug=$1',[slug])).rows[0];
|
||||
if(!post||!chapters.some(c=>c.key===post.page_key))return null;
|
||||
const pages=(await db().query('SELECT page_index,asset_id,rendered_at,revision FROM page_snapshots WHERE post_id=$1 ORDER BY page_index',[post.id])).rows;
|
||||
const job=(await db().query('SELECT status,error,revision FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null;
|
||||
return {postId:post.id,pages,job};
|
||||
}
|
||||
export async function queueSnapshots(postId:string,pageIndex?:number){
|
||||
await db().query(`INSERT INTO render_jobs(post_id,status,revision,target_page) VALUES($1,'pending',$2,$3) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,target_page=excluded.target_page,attempts=0,error=NULL WHERE render_jobs.status NOT IN ('pending','processing')`,[postId,process.env.RELEASE_SHA||'development',pageIndex??null]);
|
||||
}
|
||||
export async function renderOne(){
|
||||
const job=await transaction(async c=>(await c.query(`UPDATE render_jobs SET status='processing',attempts=attempts+1,locked_at=now() WHERE post_id=(SELECT post_id FROM render_jobs WHERE status='pending' OR (status='processing' AND locked_at<now()-interval '10 minutes') ORDER BY post_id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)).rows[0]);
|
||||
if(!job)return false;
|
||||
const dir=await mkdtemp(join(tmpdir(),'prompt-snapshots-'));let browser;
|
||||
try{
|
||||
const post=(await db().query('SELECT slug,page_key FROM posts WHERE id=$1',[job.post_id])).rows[0];
|
||||
if(!chapters.some(c=>c.key===post.page_key&&c.slug===post.slug))throw Error('Unknown coded page');
|
||||
browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const context=await browser.newContext({viewport:{width:1080,height:1440},deviceScaleFactor:1,locale:'zh-CN',timezoneId:'Asia/Shanghai',reducedMotion:'reduce'});
|
||||
const origin=new URL(process.env.RENDER_ORIGIN||'http://web:3000').origin;
|
||||
await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
|
||||
const page=await context.newPage();const output:{index:number;id:string}[]=[];
|
||||
for(let index=job.target_page||1;index<=(job.target_page||8);index++){
|
||||
const response=await page.goto(`${origin}/p/${post.slug}/pages/${index}`,{waitUntil:'networkidle',timeout:45000});
|
||||
if(!response?.ok())throw Error('Canvas page unavailable');
|
||||
await page.locator('[data-render-page]').waitFor();await page.evaluate(()=>document.fonts.ready);
|
||||
const overflow=await page.evaluate(()=>{const sheet=document.querySelector('.learning-sheet');if(!sheet)return false;const footer=sheet.querySelector('footer')!;return Array.from(sheet.children).filter(c=>c!==footer).some(c=>c.getBoundingClientRect().bottom>footer.getBoundingClientRect().top-2)});
|
||||
if(overflow)throw Error(`Canvas ${index} overlaps its footer`);
|
||||
const path=join(dir,`${index}.png`);const bytes=await page.locator('[data-render-page]').screenshot({path,animations:'disabled'});
|
||||
const hash=createHash('sha256').update(bytes).digest('hex');const key=`snapshots/${hash}.png`;const size=await putFile(key,path,'image/png');
|
||||
const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id});
|
||||
}
|
||||
await transaction(async c=>{
|
||||
const current=(await c.query('SELECT revision FROM render_jobs WHERE post_id=$1 FOR UPDATE',[job.post_id])).rows[0];
|
||||
if(current.revision!==job.revision)return;
|
||||
for(const result of output){
|
||||
await c.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,$2,$3,$4) ON CONFLICT(post_id,page_index) DO UPDATE SET asset_id=excluded.asset_id,revision=excluded.revision,rendered_at=now()`,[job.post_id,result.index,result.id,job.revision]);
|
||||
await c.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'content') ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role='content'`,[job.post_id,result.id,result.index===1?0:result.index+1]);
|
||||
}
|
||||
// Position zero remains the shared series cover reference; captions remain at one.
|
||||
if(output[0].index===1)await c.query('UPDATE posts SET cover_asset_id=$2,updated_at=now() WHERE id=$1',[job.post_id,output[0].id]);
|
||||
await c.query("UPDATE render_jobs SET status='ready',error=NULL WHERE post_id=$1",[job.post_id]);
|
||||
});
|
||||
}catch(e){await db().query("UPDATE render_jobs SET status=$2,error=$3 WHERE post_id=$1 AND revision=$4",[job.post_id,job.attempts<3?'pending':'failed',e instanceof Error?e.message:'Screenshot failed',job.revision]);console.error('Page screenshot job failed',job.post_id,e instanceof Error?e.message:'');await new Promise(r=>setTimeout(r,3000));}
|
||||
finally{await browser?.close();await rm(dir,{recursive:true,force:true});}
|
||||
return true;
|
||||
}
|
||||
@@ -1,33 +1,9 @@
|
||||
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。"],
|
||||
workflow: ["从 /skills/street-people/SKILL.md 入口阅读文档与子文档,再读取风格规范,将 {{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/<slug>", 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,不混入同一包。
|
||||
`;}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/skill-docs.css";
|
||||
import "@/styles/learning-series.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type {NextApiRequest,NextApiResponse} from "next";
|
||||
import {snapshotState,queueSnapshots} from "@/lib/snapshots";
|
||||
import {sameOrigin,apiError} from "@/lib/api";
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
if(!['GET','POST'].includes(req.method||''))return res.status(405).end();
|
||||
try{const state=await snapshotState(String(req.query.slug));if(!state)return res.status(404).end();if(req.method==='POST'){sameOrigin(req);const page=req.body?.page;if(page!==undefined&&(!Number.isInteger(page)||page<1||page>8))return res.status(400).json({error:'页码应为 1—8'});await queueSnapshots(state.postId,page);return res.status(202).json({queued:true});}return res.json(state);}catch(e){return apiError(res,e);}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {documentMarkdown} from '@/content/skills/catalog';
|
||||
export default function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const name=String(req.query.doc).replace(/\.md$/,'');const text=documentMarkdown(name==='SKILL'?'index':name);if(!text)return res.status(404).end();res.setHeader('Content-Type','text/markdown; charset=utf-8');res.setHeader('Cache-Control','no-cache');return req.method==='HEAD'?res.end():res.send(text)}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {NextApiRequest,NextApiResponse} from "next";
|
||||
import {upload} from "../upload";
|
||||
import {streetPeople,skillMarkdown} from "@/lib/street-people";
|
||||
import {streetPeople} from "@/lib/street-people";
|
||||
import {documentMarkdown} from "@/content/skills/catalog";
|
||||
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());
|
||||
if(req.query.format==="md")return res.setHeader("Content-Type","text/markdown; charset=utf-8").status(200).send(documentMarkdown("index"));
|
||||
return res.status(200).json(streetPeople);
|
||||
}
|
||||
if(req.method==="POST")return upload(req,res,{validateImage:validateCharacter,defaultTitle:"街头小人 · 新生成"});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Layers,Copy,Upload } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaUrl,type Asset } from "@/lib/model";
|
||||
type Row=Asset&{uses:number};
|
||||
export default function Assets({assets,next}:{assets:Row[];next:string|null}){
|
||||
const [copied,setCopied]=useState('');
|
||||
return <Shell><Head><title>原始素材 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">ONE ASSET, MANY POSSIBILITIES</span><h1>素材库</h1><p>查看全部原始文件和文字,复用已收录的素材。</p></div><Link className="secondary-button" href="/library/import"><Upload size={15}/>导入素材</Link></header>
|
||||
{assets.length?<div className="asset-grid">{assets.map(a=><article className="asset-tile" key={a.id}><div className="asset-preview">{a.kind==='image'?<img src={mediaUrl(a.id)} alt={a.filename||''} loading="lazy"/>:a.kind==='audio'?<audio controls preload="none" src={mediaUrl(a.id)}/>:a.kind==='video'?<video controls preload="metadata" src={mediaUrl(a.id)}/>:<p>{a.body?.slice(0,300)}</p>}</div><div className="asset-tile-info"><h3>{a.filename||'文字片段'}</h3><span>{a.uses} 个素材包正在引用</span><button aria-label="复制素材 ID" onClick={async()=>{await navigator.clipboard.writeText(a.id);setCopied(a.id);}}><Copy size={14}/>{copied===a.id?'已复制':'复制 ID'}</button></div></article>)}</div>:<div className="empty-state"><Layers size={38}/><h2>素材架还是空的</h2><p>导入的图片、视频、声音,以及素材包中的文字都会保存在这里。</p></div>}
|
||||
{next&&<Link className="secondary-button" href={'/library/assets?before='+encodeURIComponent(next)}>下一页素材</Link>}
|
||||
</Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async({query})=>{
|
||||
const before=String(query.before||'');const valid=/^[0-9a-f-]{36}$/.test(before);
|
||||
const result=await db().query(`SELECT a.*,(SELECT count(DISTINCT post_id)::integer FROM post_assets WHERE asset_id=a.id) AS uses,'[]'::json AS variants FROM assets a ${valid?'WHERE (a.created_at,a.id)<(SELECT created_at,id FROM assets WHERE id=$1)':''} ORDER BY a.created_at DESC,a.id DESC LIMIT 49`,valid?[before]:[]);
|
||||
const rows=result.rows.slice(0,48).map(({created_at,...a})=>a);return {props:{assets:rows,next:result.rows.length>48?rows.at(-1).id:null}};
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { useState,type FormEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Hash,CornerDownRight } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { tags } from "@/lib/library";
|
||||
import type { Tag } from "@/lib/model";
|
||||
export default function TagsPage({allTags}:{allTags:Tag[]}){
|
||||
const router=useRouter();const [error,setError]=useState('');const [busy,setBusy]=useState(false);
|
||||
async function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const form=e.currentTarget;const data=new FormData(form);setBusy(true);setError('');try{const r=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:data.get('name'),slug:data.get('slug'),parentId:data.get('parentId')||null})});if(!r.ok)throw new Error((await r.json()).error);form.reset();await router.replace(router.asPath);}catch(e){setError(e instanceof Error?e.message:'保存失败');}finally{setBusy(false);}}
|
||||
return <Shell><Head><title>分类标签 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">A PLACE FOR EVERY IDEA</span><h1>分类管理</h1><p>按层级整理标签。同一个素材包可以同时归入多个分类。</p></div></header><div className="management-grid"><section className="surface"><h2>分类树 <small>{allTags.length} 个分类</small></h2>{allTags.length?allTags.map(t=><div className="taxonomy-row" key={t.id} style={{paddingLeft:18+t.path.split('/').length*18}}>{t.parent_id?<CornerDownRight size={16}/>:<Hash size={16}/>}<strong>{t.name}</strong><code>/{t.path}</code></div>):<div className="quiet-empty">还没有分类。先从一个大方向开始。</div>}</section><form className="surface compact-form" onSubmit={submit}><h2>新建分类</h2><label>分类名称<input required name="name" maxLength={60} placeholder="比如:手绘线条"/></label><label>网址标识<input required name="slug" pattern="[a-z0-9][a-z0-9-]*" maxLength={60} placeholder="hand-drawn"/><small>使用小写字母、数字和连字符。</small></label><label>上级分类<select name="parentId"><option value="">作为一级分类</option>{allTags.map(t=><option key={t.id} value={t.id}>{t.path.split('/').map((_,i)=>allTags.find(a=>a.path===t.path.split('/').slice(0,i+1).join('/'))?.name).join(' / ')}</option>)}</select></label>{error&&<p className="error-message" role="alert">{error}</p>}<button className="primary-button" disabled={busy}>{busy?'保存中…':'保存分类'}</button></form></div></Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({props:{allTags:await tags()}});
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useEffect,useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { ArrowLeft,ChevronLeft,ChevronRight,Volume2,VolumeX,Download,Copy,Layers } from "lucide-react";
|
||||
import { ArticleGallery } from "@/components/ArticleGallery";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { getPost } from "@/lib/library";
|
||||
import { mediaUrl,sections,type Post } from "@/lib/model";
|
||||
@@ -15,7 +16,7 @@ export default function PostDetail({post}:{post:Post}){
|
||||
const Work=post.page_key?works[post.page_key]?.Component:undefined;
|
||||
return <Shell><Head><title>{post.title} · PROMPT</title></Head><div className="detail-top"><Link href={`/${section.path}`}><ArrowLeft size={17}/>返回{section.name}</Link><span>{section.name}</span></div>
|
||||
<div className={`detail-layout ${Work?'coded-detail':''}`}><section className="detail-media">
|
||||
{Work?<Work/>:selected?<><div className={`media-stage stage-${selected.kind}`}>
|
||||
{Work?<ArticleGallery key={post.slug} slug={post.slug}/>:selected?<><div className={`media-stage stage-${selected.kind}`}>
|
||||
{selected.kind==='image'&&<img src={mediaUrl(selected.id)} alt={selected.filename||post.title}/>}
|
||||
{selected.kind==='video'&&<video key={selected.id} controls playsInline preload="metadata" poster={selected.variants.includes('poster')?mediaUrl(selected.id,'poster'):undefined} src={mediaUrl(selected.id,selected.variants.includes('silent_video')?'silent_video':undefined)}/>}
|
||||
{selected.kind==='audio'&&<div className="audio-stage"><div className="vinyl"><span/></div><h3>{selected.filename}</h3><audio key={selected.id} controls preload="metadata" src={mediaUrl(selected.id)}/></div>}
|
||||
@@ -23,7 +24,7 @@ export default function PostDetail({post}:{post:Post}){
|
||||
</div>
|
||||
{selected.kind==='video'&&<div className="video-companion"><div><span className="pill">{selected.has_audio?<><Volume2 size={14}/>原视频有声音</>:<><VolumeX size={14}/>原视频无声音</>}</span><small>{selected.processing_status==='ready'?'画面已分离,可独立预览与复用':selected.processing_status==='failed'?'音画分离未完成,原文件仍可使用':'正在后台拆分画面与声音…'}</small></div>{selected.variants.includes('audio_track')&&<div className="bound-audio"><span><Volume2 size={16}/>这段视频的声音</span><audio controls preload="metadata" src={mediaUrl(selected.id,'audio_track')}/><small>音轨随视频保存,不属于独立声音素材。</small></div>}</div>}
|
||||
{media.length>1&&<div className="thumbnail-strip">{media.map((a,i)=><button key={a.id} className={i===index?'selected':''} onClick={()=>setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'?<img src={mediaUrl(a.id)} alt=""/>:a.variants.includes('poster')?<img src={mediaUrl(a.id,'poster')} alt=""/>:<span>{a.kind==='audio'?'声音':'视频'} {i+1}</span>}</button>)}</div>}</>:<div className="empty-state"><Layers/><p>这个素材包还没有可展示的内容。</p></div>}
|
||||
</section><aside className="detail-info"><span className="eyebrow">A PIECE OF YOUR COLLECTION</span><h1>{post.title}</h1><div className="detail-date">{new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}<span>· {media.length} 份素材</span></div>
|
||||
</section><aside className="detail-info"><span className="eyebrow">A PIECE OF YOUR COLLECTION</span><h1>{post.title}</h1><div className="detail-date">{new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}<span>· {Work?8:media.length} {Work?'张图':'份素材'}</span></div>
|
||||
<div className="description">{post.assets.filter(a=>a.kind==='text').map(a=><p key={a.id}>{a.body}</p>)}</div>
|
||||
<div className="tag-list">{post.tags.map(t=><Link key={t.id} href={`/${section.path}/${t.path}`}># {t.name}</Link>)}</div>
|
||||
{selected&&<div className="asset-facts"><h3>素材信息</h3><dl><dt>原文件</dt><dd>{selected.filename}</dd>{selected.width&&<><dt>尺寸</dt><dd>{selected.width} × {selected.height}</dd></>}{selected.duration&&<><dt>时长</dt><dd>{selected.duration.toFixed(1)} 秒</dd></>}<dt>复用方式</dt><dd>引用同一素材,无需再次上传</dd></dl><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(selected.id);setCopied(true);setTimeout(()=>setCopied(false),2000);}}><Copy size={14}/>{copied?'已复制素材 ID':'复制素材 ID'}</button><a className="text-link" href={mediaUrl(selected.id)} download><Download size={14}/>打开原文件</a></div>}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Head from "next/head";
|
||||
import type {GetServerSideProps} from "next";
|
||||
import {chapters} from "@/content/learning-skills/data";
|
||||
import {LearningSeries} from "@/content/learning-skills/Series";
|
||||
export default function Canvas({chapterKey,page,title}:{chapterKey:string;page:number;title:string}){return <><Head><title>{`${title} · 第 ${page+1} 张`}</title><meta name="robots" content="noindex"/></Head><LearningSeries key={`${chapterKey}-${page}`} chapterKey={chapterKey} pageNumber={page} standalone/></>}
|
||||
export const getServerSideProps:GetServerSideProps=async({params})=>{const chapter=chapters.find(c=>c.slug===params?.slug);const n=String(params?.page);if(!chapter||!/^([1-8])$/.test(n))return {notFound:true};return {props:{chapterKey:chapter.key,page:Number(n)-1,title:chapter.title}}};
|
||||
@@ -1,5 +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 <Shell><Head><title>技能 · PROMPT</title></Head><header className="page-header"><div><h1>技能</h1><p>把一种喜欢的画风,变成可以反复调用的创作方法。</p></div></header><Link className="skill-card" href="/tools/skills/street-people"><div className="skill-art"><img src="/skills/street-people/sample.png" alt="手绘街头小人,透明剪纸素材"/></div><div><span className="pill">图片生成 · 透明 PNG</span><h2>街头小人 <ArrowUpRight size={22}/></h2><p>松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。</p><span>查看文档与 Agent API →</span></div></Link></Shell>}
|
||||
import Head from 'next/head';
|
||||
import {Shell} from '@/components/Shell';
|
||||
import {SkillCard} from '@/components/SkillCard';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
export default function Skills(){return <Shell><Head><title>技能 · PROMPT</title></Head><header className="page-header"><div><h1>技能</h1><p>把一种喜欢的画风,变成可以反复调用的创作方法。</p></div></header><div className="skills-masonry">{skillCatalog.map(skill=><SkillCard key={skill.id} skill={skill}/>)}</div></Shell>}
|
||||
|
||||
@@ -1,14 +1 @@
|
||||
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 <Shell><Head><title>街头小人 · 技能 · PROMPT</title></Head><div className="detail-top"><Link href="/tools/skills">← 全部技能</Link><span>v{streetPeople.version}</span></div><div className="skill-doc"><header className="skill-hero"><div><span className="pill">STREET PEOPLE / 01</span><h1>街头,一个小小的瞬间。</h1><p>用不太规整的线条,画一个正在生活的人。<br/>奶油色剪纸边,深紫、钴蓝或珊瑚红的笔触,外部背景透明。</p><button className="primary-button" onClick={async()=>{await navigator.clipboard.writeText(`${location.origin}/api/skills/street-people`);setCopied(true)}}>{copied?'已复制 API 地址':'复制 API 给 coding agent'}</button></div><div className="skill-art"><img src="/skills/street-people/sample.png" alt="新生成的街头小人示例"/></div></header><div className="skill-doc-grid"><article><h2>01 / 画风约定</h2><p>完整单人、成人比例、稀疏五官;不规则毡头笔线条,头发与裤子用涂抹排线。保留纸片内部的奶油色,纸片以外是真实透明通道。人物动作和随身物件可以变化。</p><h2>02 / 提示词</h2><p>把下方的 {'{{subject}}'} 替换成衣着与动作。每张单独生成,使用绘图工具的透明背景选项。</p><pre>{streetPeople.prompt}</pre><h2>03 / 从生成到图包</h2><ol>{streetPeople.workflow.map(s=><li key={s}>{s}</li>)}</ol><p>参考图提取与新生成作品分开提交,各对应一个 post。建议在说明中记录来源、提示词与工具。</p><h2>04 / 调用示例</h2><pre>{`# 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'`}</pre><p>成功返回 201,包含 post 的 id、slug、url 与素材 assets ID。400 表示文件或表单不符合规范,409 表示 slug 已占用,413 表示超出上传限制。每次最多 16 张 PNG,单个 250 MB、总计 1 GB;人物 PNG 最多 4000 万像素。</p></article><aside className="surface skill-sidebar"><h2>交给 Agent</h2><p>把接口地址发给有绘图能力的 coding agent,它读取规范、生成 PNG,再上传到同一接口。</p><code>GET /api/skills/street-people</code><code>POST /api/skills/street-people</code><p>绘图由 agent 的工具执行;网站提供规范、透明校验和存储发布,无需在网站配置模型密钥。</p><a className="secondary-button" href="/api/skills/street-people?format=md">读取 Markdown 文档</a><a className="secondary-button" href="/api/skills/street-people">读取 JSON 规范</a><Link href="/p/street-people-reference" className="secondary-button">参考图提取包 ↗</Link><Link href="/p/street-people-original" className="secondary-button">新生成测试包 ↗</Link></aside></div></div></Shell>}
|
||||
export {SkillDocs as default} from '@/components/SkillDocs';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {SkillDocs} from '@/components/SkillDocs';
|
||||
import {skillDocuments} from '@/content/skills/catalog';
|
||||
export default SkillDocs;
|
||||
export const getServerSideProps:GetServerSideProps=async({params})=>{const id=String(params?.doc);return id!=='index'&&skillDocuments.some(d=>d.id===id)?{props:{documentId:id}}:{notFound:true}};
|
||||
@@ -2,3 +2,4 @@
|
||||
@media(max-width:650px){.learning-reader{padding:16px 10px;border-radius:14px}.learning-reader-bar{gap:10px;margin-bottom:16px;padding:0 3px}.learning-reader-bar strong{font-size:12px;line-height:1.6}.learning-reader-bar>button{font-size:11px;padding:9px 10px}.learning-reader-badge{font-size:10px}.learning-related a{font-size:11px;line-height:1.7}.learning-pagination{margin-top:17px}.learning-sheet{font-synthesis:none}}
|
||||
.detail-layout.coded-detail{grid-template-columns:minmax(0,1fr);max-width:1050px;border:0;box-shadow:none;overflow:visible}.coded-detail .detail-media{background:transparent}.coded-detail .detail-info{margin:28px auto 0;max-width:780px;width:100%;border-top:1px solid #e5e7df}.coded-detail .detail-info>.eyebrow{display:none}
|
||||
.learning-index button{padding:1cqw 0}
|
||||
.learning-standalone{width:1080px;height:1440px;margin:0;background:#fbfaf5;overflow:hidden}.learning-standalone .learning-boards{width:1080px;max-width:none;margin:0;container-type:inline-size}.learning-standalone .learning-cover,.learning-standalone .learning-sheet{box-shadow:none}.learning-html-cover svg{display:block;width:100%;height:100%}.snapshot-reader{padding:24px;background:#f2f2ef;border-radius:20px}.snapshot-reader>header{display:flex;justify-content:space-between;align-items:center;gap:15px;margin:0 auto 20px;max-width:800px}.snapshot-reader header p{font-size:12px;color:#888}.snapshot-reader header b{font-size:15px}.snapshot-reader header button{font-size:12px}.snapshot-stage{max-width:720px;margin:auto;aspect-ratio:3/4;background:white}.snapshot-stage img{display:block;width:100%;height:auto}.snapshot-placeholder{height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:20px;color:#888}.snapshot-reader>nav{display:flex;align-items:center;justify-content:center;gap:20px;font-size:13px;margin:20px 0}.snapshot-reader nav button{border-radius:50%;padding:9px;background:white}.snapshot-reader nav a{display:flex;align-items:center;gap:7px;color:#555}.snapshot-reader button:disabled{opacity:.4;cursor:default}.snapshot-thumbs{display:flex;max-width:720px;gap:8px;margin:auto}.snapshot-thumbs>button{width:calc(100% / 8);aspect-ratio:3/4;background:#e5e5df;border:2px solid transparent;border-radius:8px;overflow:hidden}.snapshot-thumbs>button[aria-pressed=true]{border-color:#333}.snapshot-thumbs img{width:100%;height:100%;object-fit:cover}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.skills-masonry{columns:4 230px;column-gap:24px}.skill-pin{display:block;break-inside:avoid;margin:0 0 28px}.skill-pin-cover{background:#f1eee8!important;aspect-ratio:4/5;display:grid;place-items:center;max-height:440px}.skill-pin-cover img{width:100%;height:100%;object-fit:contain!important;padding:24px}.skill-pin-category{display:block;font-size:11px;color:#a08873;margin:14px 4px 8px}.skill-pin p{font-size:13px;line-height:1.8;color:#777;margin:8px 4px 12px}.skill-pin-link{font-size:12px;margin:0 4px;color:#5e6b56}.docs-top{display:flex;justify-content:space-between;font-size:13px;color:#777;gap:15px;margin-bottom:28px}.docs-top button{display:flex;align-items:center;gap:8px}.docs-layout{max-width:1200px;margin:auto;display:grid;gap:35px;grid-template-columns:minmax(0,800px);justify-content:center}.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 340px}.docs-body{min-width:0;padding:18px 12px 70px}.docs-eyebrow{font-size:11px;color:#9b968b;letter-spacing:1.5px}.docs-body h1{font-size:38px;font-weight:700;margin:18px 0 24px;letter-spacing:-1px}.docs-body h2{font-size:23px;margin:40px 0 20px;font-weight:650}.docs-body>p{font-size:15px;color:#555;line-height:2;margin:22px 0}.docs-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px}.docs-actions button{display:flex;align-items:center;gap:8px;background:#252525;color:white;padding:12px 16px;border-radius:24px}.docs-actions a{color:#888}.docs-body>.docs-hint{font-size:12px;color:#999}.docs-body pre,.docs-resource-preview pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f5f2;padding:22px;border-radius:15px;font-size:12px;line-height:1.9;color:#666}.docs-inline-links{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}.docs-inline-links a{display:flex;align-items:center;gap:7px;border:1px solid #e4e4df;border-radius:20px;padding:9px 12px;font-size:12px;color:#64705b}.docs-sources{position:sticky;top:105px;align-self:start;max-height:calc(100vh - 125px);overflow:auto;border:1px solid #e6e6e1;border-radius:18px;background:white;box-shadow:0 8px 35px #00000008;padding:20px}.docs-sources>header{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;border-bottom:1px solid #eee;padding-bottom:18px}.docs-sources header b{font-size:16px}.docs-sources header small{display:block;font-size:11px;color:#aaa;margin-top:7px}.docs-sources h3{font-size:12px;color:#999;margin:21px 0 10px}.docs-sources section>button{display:flex;text-align:left;align-items:flex-start;gap:9px;width:100%;padding:12px 8px;border-radius:10px}.docs-sources section>button.selected,.docs-sources section>button:hover{background:#f2f3ed}.docs-sources section>button>span{font-size:9px;border:1px solid #ddd;padding:3px 4px;border-radius:5px;color:#92987f;flex-shrink:0}.docs-sources section>button>div{font-size:12px;color:#555;min-width:0}.docs-sources section small{display:block;font-size:10px;overflow-wrap:anywhere;color:#aaa;margin-top:5px}.docs-resource-preview{border-top:1px solid #ddd;margin-top:20px}.docs-resource-preview img{width:100%;max-height:350px;object-fit:contain;background:repeating-conic-gradient(#eee 0 25%,white 0 50%) 0/16px 16px}.docs-resource-preview pre{font-size:10px;max-height:330px;overflow:auto;padding:12px}.docs-resource-preview p,.docs-resource-preview code{font-size:11px;line-height:1.8;overflow-wrap:anywhere}.docs-resource-actions{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:#60724e;margin:18px 0}.docs-body a:focus-visible,.docs-sources button:focus-visible{outline:2px solid #81956d;outline-offset:3px}
|
||||
@media(max-width:1050px){.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 285px;gap:18px}}@media(max-width:760px){.docs-layout.with-sources{display:block}.docs-sources{position:fixed;right:12px;left:12px;top:95px;bottom:18px;max-height:none;z-index:45;box-shadow:0 0 0 100vmax #0005}.docs-body h1{font-size:30px}.docs-body{padding:10px 0 40px}.docs-body>p{font-size:14px}.skills-masonry{columns:2;column-gap:15px}.skill-pin-cover img{padding:12px}.skill-pin p{font-size:12px}.snapshot-reader{padding:14px 8px}.snapshot-reader>header{align-items:flex-start;flex-direction:column}.snapshot-reader nav{gap:10px}.snapshot-reader nav a{font-size:11px}}
|
||||
+2
-1
@@ -8,6 +8,7 @@ import { setTimeout } from "node:timers/promises";
|
||||
import { db,transaction } from "./lib/db";
|
||||
import { getObject,putFile } from "./lib/storage";
|
||||
import { probe,run } from "./lib/probe";
|
||||
import {renderOne} from "./lib/snapshots";
|
||||
let stopping=false;
|
||||
process.on('SIGTERM',()=>{stopping=true;});process.on('SIGINT',()=>{stopping=true;});
|
||||
async function processOne(){
|
||||
@@ -43,7 +44,7 @@ async function processOne(){
|
||||
async function main(){
|
||||
const heartbeat=()=>writeFile('/tmp/prompt-worker-health',String(Date.now())).catch(()=>console.error('Worker heartbeat failed'));
|
||||
await heartbeat();const timer=setInterval(()=>{void heartbeat();},15000);
|
||||
try{while(!stopping){try{if(!await processOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
|
||||
try{while(!stopping){try{if(!await processOne()&&!await renderOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
|
||||
finally{clearInterval(timer);await db().end();}
|
||||
}
|
||||
main().catch(()=>process.exit(1));
|
||||
|
||||
@@ -13,7 +13,7 @@ import {db} from '../src/lib/db';
|
||||
import {probe,run} from '../src/lib/probe';
|
||||
import {publishLearningSeries} from '../src/lib/publish-learning-series';
|
||||
|
||||
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:180000}, async()=>{
|
||||
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:300000}, async()=>{
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
|
||||
const external=process.env.PROMPT_TEST_BASE_URL;
|
||||
@@ -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','/tools/skills','/tools/skills/street-people'])assert.equal((await fetch(base+route)).status,200,route);
|
||||
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/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();
|
||||
@@ -47,6 +47,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
|
||||
}
|
||||
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio'")).rows[0].n,0,'Bound soundtracks are variants, not independent audio assets');
|
||||
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
|
||||
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
|
||||
const published=await publishLearningSeries();await publishLearningSeries();
|
||||
const articles=(await request('/api/feed?section=articles')).posts;
|
||||
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
|
||||
@@ -57,6 +58,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');
|
||||
for(const slug of published.published){
|
||||
for(const n of [1,3,8]){const r=await fetch(base+`/p/${slug}/pages/${n}`);assert.equal(r.status,200);const html=await r.text();assert.ok(html.includes('data-render-page'));assert.ok(!html.includes('global-topbar'));}
|
||||
let status;for(let i=0;i<120;i++){status=await request(`/api/posts/${slug}/snapshots`);if(status.job?.status==='failed')assert.fail(status.job.error);if(status.job?.status==='ready'&&status.pages.length===8)break;await setTimeout(500)}
|
||||
assert.equal(status.pages.length,8,'All eight HTML pages must be screenshot on the server');
|
||||
const r:Response=await fetch(base+'/api/media/'+status.pages[2].asset_id);const meta:{width?:number;height?:number}=await sharp(Buffer.from(await r.arrayBuffer())).metadata();assert.equal(meta.width,1080);assert.equal(meta.height,1440);
|
||||
}
|
||||
const before=await request(`/api/posts/${published.published[0]}/snapshots`);
|
||||
const refresh=await fetch(base+`/api/posts/${published.published[0]}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({page:3})});assert.equal(refresh.status,202);
|
||||
let after;for(let i=0;i<80;i++){after=await request(`/api/posts/${published.published[0]}/snapshots`);if(after.job?.status==='ready'&&after.pages[2].rendered_at!==before.pages[2].rendered_at)break;await setTimeout(500)}
|
||||
assert.notEqual(after.pages[2].rendered_at,before.pages[2].rendered_at);assert.equal(after.pages[1].rendered_at,before.pages[1].rendered_at,'Single-page refresh preserves other snapshots');
|
||||
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
|
||||
const home=await fetch(base+'/').then(r=>r.text());assert.ok(home.includes('查看文档与 Agent 入口'));
|
||||
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});};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {skillDocuments,skillResources,documentMarkdown} from '../src/content/skills/catalog';
|
||||
test('human and agent documents share a complete linked resource registry',()=>{
|
||||
for(const doc of skillDocuments){const md=documentMarkdown(doc.id)!;assert.ok(md.startsWith('# '+doc.title));for(const block of doc.blocks){if(block.text)assert.ok(md.includes(block.text));for(const id of block.refs||[])assert.ok(skillResources.some(r=>r.id===id))}assert.ok(!md.includes('/p/street-people-'));}
|
||||
assert.equal(documentMarkdown('missing'),null);assert.equal(new Set(skillResources.map(r=>r.id)).size,skillResources.length);
|
||||
for(const doc of skillDocuments)assert.ok(skillResources.some(r=>r.id===doc.id&&r.human&&r.href.endsWith('.md')));
|
||||
});
|
||||
Reference in New Issue
Block a user