From a60d19b3c9f56c26996defd348d5fb10809ceae0 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 23 Oct 2026 11:59:44 +0800 Subject: [PATCH] Separate authored canvases from post snapshots and unify linked skill documentation --- Dockerfile | 4 +- README.md | 11 +++- compose.yml | 2 + db/002_page_snapshots.sql | 5 ++ docs/learning-series.md | 9 +++ next.config.ts | 3 + package-lock.json | 13 +++++ package.json | 5 +- scripts/deploy.sh | 1 + scripts/wait-snapshots.mjs | 8 +++ src/components/ArticleGallery.tsx | 13 +++++ src/components/Feed.tsx | 13 +++-- src/components/Shell.tsx | 2 +- src/components/SkillCard.tsx | 3 + src/components/SkillDocs.tsx | 16 ++++++ src/content/learning-skills/Series.tsx | 8 ++- src/content/learning-skills/cover.ts | 1 + src/content/skills/catalog.ts | 35 ++++++++++++ src/lib/publish-learning-series.ts | 7 ++- src/lib/snapshots.ts | 55 +++++++++++++++++++ src/lib/street-people.ts | 26 +-------- src/pages/_app.tsx | 1 + src/pages/api/posts/[slug]/snapshots.ts | 8 +++ .../api/skill-docs/street-people/[doc].ts | 3 + src/pages/api/skills/street-people.ts | 5 +- src/pages/library/assets.tsx | 21 ------- src/pages/library/tags.tsx | 14 ----- src/pages/p/[slug].tsx | 5 +- src/pages/p/[slug]/pages/[page].tsx | 6 ++ src/pages/tools/skills/index.tsx | 10 ++-- src/pages/tools/skills/street-people.tsx | 15 +---- .../tools/skills/street-people/[doc].tsx | 5 ++ src/styles/learning-series.css | 1 + src/styles/skill-docs.css | 2 + src/worker.ts | 3 +- tests/integration.test.ts | 17 +++++- tests/skill-docs.test.ts | 8 +++ 37 files changed, 260 insertions(+), 104 deletions(-) create mode 100644 db/002_page_snapshots.sql create mode 100644 scripts/wait-snapshots.mjs create mode 100644 src/components/ArticleGallery.tsx create mode 100644 src/components/SkillCard.tsx create mode 100644 src/components/SkillDocs.tsx create mode 100644 src/content/learning-skills/cover.ts create mode 100644 src/content/skills/catalog.ts create mode 100644 src/lib/snapshots.ts create mode 100644 src/pages/api/posts/[slug]/snapshots.ts create mode 100644 src/pages/api/skill-docs/street-people/[doc].ts delete mode 100644 src/pages/library/assets.tsx delete mode 100644 src/pages/library/tags.tsx create mode 100644 src/pages/p/[slug]/pages/[page].tsx create mode 100644 src/pages/tools/skills/street-people/[doc].tsx create mode 100644 src/styles/skill-docs.css create mode 100644 tests/skill-docs.test.ts diff --git a/Dockerfile b/Dockerfile index 8bd0599..fa858ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 57f05f2..b1f1082 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,6 @@ docker compose --env-file .env up -d --wait web worker - 上述五类路径可以附加标签树路径,如 `/images/style/white-line`;父分类会包含后代分类的内容。 - `/p/`:素材包的稳定详情地址,不随标签改变。多图切换、多视频及其关联音轨、多声音均可展示。 - `/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//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` 兼容保留,后者返回相同入口文档。 diff --git a/compose.yml b/compose.yml index 669ff6a..484d005 100644 --- a/compose.yml +++ b/compose.yml @@ -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} diff --git a/db/002_page_snapshots.sql b/db/002_page_snapshots.sql new file mode 100644 index 0000000..75d4b46 --- /dev/null +++ b/db/002_page_snapshots.sql @@ -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; diff --git a/docs/learning-series.md b/docs/learning-series.md index 75b92f9..7686d16 100644 --- a/docs/learning-series.md +++ b/docs/learning-series.md @@ -23,3 +23,12 @@ Docker 集成测试会重复执行发布,检查三个作品、共用封面的 ## 验收约定 先推送 Git,再等 CI/CD 成功,最后直接访问服务器 http://8.211.154.134:18131/ 验收。不要启动本地网页预览,不把 localhost 作为展示或交付入口。 + + +## 独立网页与真实截图(新版) + +每篇笔记仍使用 `/p/`;每一张图的独立 HTML/CSS/TypeScript 原稿使用 `/p//pages/1` 至 `/pages/8`。这些原稿没有网站导航与笔记说明,固定为 1080×1440;第一张封面也是可渲染的 SVG DOM,而非直接嵌入原来的 PNG。 + +笔记页展示 `page_snapshots` 引用的 S3 PNG,可打开原稿、刷新当前一张或更新全部。POST `/api/posts//snapshots` 接收可选 `{page: 1..8}`,GET 返回状态与截图列表。服务只允许代码登记的页面,不接受任意网址。后台 worker 使用 Chromium 截图,等待字体就绪,并检查内容没有压住页脚;网络请求只允许内部 Web 服务。完整一轮成功后在事务中更新引用,失败保留旧图。图片按摘要去重,三篇封面继续共用素材。 + +部署使用 Docker 内 Chromium 与中文字体,等待 24 张当前版本截图全部完成才确认发布。没有本地浏览器预览。 diff --git a/next.config.ts b/next.config.ts index 2d0e244..d252ad8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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, }; diff --git a/package-lock.json b/package-lock.json index 7d60d24..15883a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fd1a9cc..702787c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8851a89..e8462be 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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' diff --git a/scripts/wait-snapshots.mjs b/scripts/wait-snapshots.mjs new file mode 100644 index 0000000..365e856 --- /dev/null +++ b/scripts/wait-snapshots.mjs @@ -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'); diff --git a/src/components/ArticleGallery.tsx b/src/components/ArticleGallery.tsx new file mode 100644 index 0000000..9073a54 --- /dev/null +++ b/src/components/ArticleGallery.tsx @@ -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({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
这篇笔记 · 8 张图

{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}

{error&&

{error}

}{state.job?.status==='failed'&&

截图未完成,保留上次版本。可以重新刷新。

}
{selected?{`笔记第:
这张图还没有截图。先打开网页原稿 ↗
}
{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return })}
; +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 47a339e..1d7908e 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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&&{count}}{video&&{video.has_audio?'有声音':'无声音'}}

{post.title}

{label}
{post.tags.length>0&&
{post.tags.slice(0,2).map(t=>#{t.name})}
}; } -function Masonry({posts}:{posts:Post[]}){ +function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:boolean;q?:string}){ const element=useRef(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
{columns.map((items,i)=>
{items.map(p=>)}
)}
; + 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
{columns.map((items,i)=>
{items.map(item=>item.skill?:)}
)}
; } 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&&} {tagPath&&
全部{current?.name}{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return / {initial.tags.find(t=>t.path===path)?.name};})}
}
{!section?<>全部{sections.map(s=>{s.name})}:<>全部{children.map(t=>{t.name})}}
最近收录
- {initial.unavailable?

素材库暂时没有连接上

请稍后刷新,已保存的内容不会因此丢失。

:posts.length?:
A LITTLE SPACE FOR SOMETHING GOOD

{router.query.q?'还没有找到这份灵感':'这里还没有内容'}

{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}

导入素材图文和动态作品通过代码加入,自由决定每一个像素。
} + {initial.unavailable?

素材库暂时没有连接上

请稍后刷新,已保存的内容不会因此丢失。

:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?:
A LITTLE SPACE FOR SOMETHING GOOD

{router.query.q?'还没有找到这份灵感':'这里还没有内容'}

{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}

导入素材图文和动态作品通过代码加入,自由决定每一个像素。
} {cursor&&
}
PROMPT · PROBABLY RENDERING ONE MORE PIXEL TONIGHT
; diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 2681158..0537eed 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},{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||'')); diff --git a/src/components/SkillCard.tsx b/src/components/SkillCard.tsx new file mode 100644 index 0000000..117b0a1 --- /dev/null +++ b/src/components/SkillCard.tsx @@ -0,0 +1,3 @@ +import Link from 'next/link'; +import {skillCatalog} from '@/content/skills/catalog'; +export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return
{skill.alt}/
{skill.category}

{skill.title}

{skill.description}

查看文档与 Agent 入口 ↗} diff --git a/src/components/SkillDocs.tsx b/src/components/SkillDocs.tsx new file mode 100644 index 0000000..91aedf3 --- /dev/null +++ b/src/components/SkillDocs.tsx @@ -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(null);const[copied,setCopied]=useState(false);const panel=useRef(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 {`${doc.title} · 技能文档 · PROMPT`}
← 全部技能
街头小人 / SKILL DOCUMENTS

{doc.title}

纯文本版本 ↗

网页与 Agent 纯文本版本来自同一份文档。参考图、子文档与接口均通过链接连接。

{doc.blocks.map((b,i)=>b.type==='heading'?

{b.text}

:b.type==='code'?
{b.text}
:b.type==='links'?:

{b.text}

)}
{open&&}
; +} diff --git a/src/content/learning-skills/Series.tsx b/src/content/learning-skills/Series.tsx index 4cec45f..e818b01 100644 --- a/src/content/learning-skills/Series.tsx +++ b/src/content/learning-skills/Series.tsx @@ -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
{String(index+1).padStart(2,"0")}
{skill.category}

{skill.title}

{skill.name}

给它{skill.input}

得到{skill.output}

可以这样问

“{skill.prompt}”

{skill.note}

; } 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[]=[ -
学习资料,自己做。笔记整理、练习试卷、英语学习,30 个 AI Skills 收藏清单。
, +
,
第 {chapter.number} 辑 · 10 个 Skills

{chapter.headline[0]}
{chapter.headline[1]}

{chapter.subtitle}

{chapter.audience}
{chapter.skills.map((skill,i)=>)}

按需求挑选,不必一次装完。点击条目查看介绍。

, ...Array.from({length:5},(_,i)=>
把工具用在具体的事情上

{["先整理,再开始。","把资料做成自己的。","让学习多一条路径。","把看过变成理解。","留下一份能复用的资料。"][i]}

{chapter.skills.slice(i*2,i*2+2).map((skill,j)=>)}
),
收藏之后,试着做一份。

先选一个需求,
再串成工作流。

不求用遍所有工具,先完成一份自己的资料。

    {chapter.workflow.map((line,i)=>
  1. {i+1}{line}{i<2&&}
  2. )}

10 个来源,放在这里了。

{chapter.skills.map((skill,i)=>{String(i+1).padStart(2,"0")}{skill.name})}

依据 GitHub 项目说明整理 · 核对于 {sourceChecked}
尚未逐项安装实测;文中小样为排版示意。

同系列还有{chapters.filter(c=>c.key!==chapter.key).map(c=>{c.number} / {c.subtitle})}
, ]; + if(standalone)return
{boards[page]}
; return
系列 {chapter.number} / 03{chapter.subtitle}
{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]}
diff --git a/src/content/learning-skills/cover.ts b/src/content/learning-skills/cover.ts new file mode 100644 index 0000000..969b789 --- /dev/null +++ b/src/content/learning-skills/cover.ts @@ -0,0 +1 @@ +export const coverSvg = "\n \n \n \n \n \n \n PROMPT 收藏夹\n THE LEARNING EDIT\n 学习资料,\n \n 自己做。\n 笔记整理 / 练习试卷 / 英语学习\n 30个 AI Skills\n \n \n 01 / 复习笔记把知识梳理清楚\n 02 / 教学资料单元练习与讲评题号一二三一、先理解,再练习。\n 03 / 英语学习Read. Learn. Use.lifelong learning语境 / 搭配 / 自己造句\n \n \n \n 三篇合集,每篇 10 个。从 GitHub 出发,把资料做成适合自己的样子。\n SAVE / LEARN / MAKE\n \n\n"; diff --git a/src/content/skills/catalog.ts b/src/content/skills/catalog.ts new file mode 100644 index 0000000..ada15a0 --- /dev/null +++ b/src/content/skills/catalog.ts @@ -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'; +} diff --git a/src/lib/publish-learning-series.ts b/src/lib/publish-learning-series.ts index 91ef3a4..0b3b227 100644 --- a/src/lib/publish-learning-series.ts +++ b/src/lib/publish-learning-series.ts @@ -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}; diff --git a/src/lib/snapshots.ts b/src/lib/snapshots.ts new file mode 100644 index 0000000..4b54904 --- /dev/null +++ b/src/lib/snapshots.ts @@ -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_atc.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; +} diff --git a/src/lib/street-people.ts b/src/lib/street-people.ts index 7ac0566..6e9ff54 100644 --- a/src/lib/street-people.ts +++ b/src/lib/street-people.ts @@ -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/", 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/_app.tsx b/src/pages/_app.tsx index 27419c8..6ed669b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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) { diff --git a/src/pages/api/posts/[slug]/snapshots.ts b/src/pages/api/posts/[slug]/snapshots.ts new file mode 100644 index 0000000..ec9dfa1 --- /dev/null +++ b/src/pages/api/posts/[slug]/snapshots.ts @@ -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);} +} diff --git a/src/pages/api/skill-docs/street-people/[doc].ts b/src/pages/api/skill-docs/street-people/[doc].ts new file mode 100644 index 0000000..394739a --- /dev/null +++ b/src/pages/api/skill-docs/street-people/[doc].ts @@ -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)} diff --git a/src/pages/api/skills/street-people.ts b/src/pages/api/skills/street-people.ts index b157916..da88efc 100644 --- a/src/pages/api/skills/street-people.ts +++ b/src/pages/api/skills/street-people.ts @@ -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:"街头小人 · 新生成"}); diff --git a/src/pages/library/assets.tsx b/src/pages/library/assets.tsx deleted file mode 100644 index e698ff8..0000000 --- a/src/pages/library/assets.tsx +++ /dev/null @@ -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 原始素材 · PROMPT
ONE ASSET, MANY POSSIBILITIES

素材库

查看全部原始文件和文字,复用已收录的素材。

导入素材
- {assets.length?
{assets.map(a=>
{a.kind==='image'?{a.filename||''}:a.kind==='audio'?

{a.filename||'文字片段'}

{a.uses} 个素材包正在引用
)}
:

素材架还是空的

导入的图片、视频、声音,以及素材包中的文字都会保存在这里。

} - {next&&下一页素材} -
; -} -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}}; -}; diff --git a/src/pages/library/tags.tsx b/src/pages/library/tags.tsx deleted file mode 100644 index 669200b..0000000 --- a/src/pages/library/tags.tsx +++ /dev/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){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 分类标签 · PROMPT
A PLACE FOR EVERY IDEA

分类管理

按层级整理标签。同一个素材包可以同时归入多个分类。

分类树 {allTags.length} 个分类

{allTags.length?allTags.map(t=>
{t.parent_id?:}{t.name}/{t.path}
):
还没有分类。先从一个大方向开始。
}

新建分类

{error&&

{error}

}
; -} -export const getServerSideProps:GetServerSideProps=async()=>({props:{allTags:await tags()}}); diff --git a/src/pages/p/[slug].tsx b/src/pages/p/[slug].tsx index a2124d6..f8ab9fd 100644 --- a/src/pages/p/[slug].tsx +++ b/src/pages/p/[slug].tsx @@ -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 {post.title} · PROMPT
返回{section.name}{section.name}
- {Work?:selected?<>
+ {Work?:selected?<>
{selected.kind==='image'&&{selected.filename||post.title}/} {selected.kind==='video'&&
{selected.kind==='video'&&
{selected.has_audio?<>原视频有声音:<>原视频无声音}{selected.processing_status==='ready'?'画面已分离,可独立预览与复用':selected.processing_status==='failed'?'音画分离未完成,原文件仍可使用':'正在后台拆分画面与声音…'}
{selected.variants.includes('audio_track')&&
这段视频的声音
}
} {media.length>1&&
{media.map((a,i)=>)}
}:

这个素材包还没有可展示的内容。

} -