From 46cd53a2a18edd35f59fdfce6f036047c0ea4961 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 23 Oct 2026 21:20:53 +0800 Subject: [PATCH] Pair every guidance page with Markdown and enable shared document references --- docs/ci-and-entrypoints.md | 8 +-- docs/guidance.md | 19 ++++++ next.config.ts | 9 ++- public/llms.txt | 9 ++- src/components/Feed.tsx | 2 +- src/components/GuidanceIndex.tsx | 12 ++++ src/components/Shell.tsx | 2 +- src/components/SkillDocs.tsx | 46 ++++++++++++-- src/content/skills/catalog.ts | 2 +- src/content/skills/guidance.ts | 62 +++++++++++++++++++ src/lib/markdown-response.ts | 9 +++ src/pages/api/agent-index.ts | 32 ++-------- src/pages/api/guidance/[[...path]].ts | 8 +++ src/pages/api/skill-docs/[skill]/[doc].ts | 10 +-- .../api/skill-docs/street-people/[doc].ts | 5 +- src/pages/api/skills/street-people.ts | 4 +- src/pages/skills/[[...path]].tsx | 10 +++ src/pages/tools/skills/index.tsx | 6 +- src/styles/skill-docs.css | 1 + tests/skill-docs.test.ts | 37 ++++++++++- 20 files changed, 229 insertions(+), 64 deletions(-) create mode 100644 docs/guidance.md create mode 100644 src/components/GuidanceIndex.tsx create mode 100644 src/content/skills/guidance.ts create mode 100644 src/lib/markdown-response.ts create mode 100644 src/pages/api/guidance/[[...path]].ts create mode 100644 src/pages/skills/[[...path]].tsx diff --git a/docs/ci-and-entrypoints.md b/docs/ci-and-entrypoints.md index 771eadc..99aab35 100644 --- a/docs/ci-and-entrypoints.md +++ b/docs/ci-and-entrypoints.md @@ -1,12 +1,12 @@ # 构建与入口 -人类默认从 `/` 进入;Agent 从 `/agent` 或 `/agent/index.md` 读取 `text/markdown` 索引,根目录 `/llms.txt` 提供发现链接。索引复用技能目录,技能继续使用 `/skills//SKILL.md` 和现有子文档 URL,避免复制文档。 +人类默认从 `/` 进入;对应 Markdown 根入口为 `/index.md`,指导文档目录为 `/skills` 与 `/skills/index.md`。每篇网页地址后加 `/index.md` 即为同内容的 Markdown,允许跨分组引用。`/agent`、`/agent/index.md` 和旧技能文档 URL 兼容保留;`/llms.txt` 提供发现链接。详见 `docs/guidance.md`。 `ces-control` 必须预装 Docker、Compose,并允许 Runner 访问持久 Docker daemon。CI 只检查环境,不安装系统包、不启停主机服务。当前服务器没有 buildx,先继续使用 daemon 自带层缓存;不要直接粘贴依赖 GitHub cache 服务的 `type=gha` 参数。后续若换 Docker daemon 或清理缓存,会重新冷构建。 -Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。push 和 PR 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。仍保留类型检查、单元测试、smoke 与完整隔离栈集成测试。 +Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。push 和 PR 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。完整路径保留类型检查、单元测试、smoke 与完整隔离栈集成测试。 -当前页面为 Next.js SSR 与编译组件,改 TSX 仍需 next build。把 HTML 的 COPY 移到最后不能避免编译。独立挂载静态文件会破坏镜像版本与回滚的一致性;只有真正独立、无需编译的内容才适合后续拆成单独发布渠道。 +当前页面为 Next.js SSR 与编译组件,改 TSX 仍需 next build。Web 快速路径只编译生产 Web,并以不可变的版本目录挂载完整 standalone 产物,保持版本与回滚一致;不重新编译 worker 或导出运行镜像。 ## Web 快速发布 @@ -16,4 +16,4 @@ Web 路径复用当前 runtime 对应的已验证 integration 镜像作为工具 完整 standalone 产物存入 `/opt/prompt/web-artifacts/.`,只读挂载到 Web 容器;仅重建 Web,保留 runtime 镜像、worker、Postgres 和 RustFS。切换前检查 main 与当前发布,部署锁与全量流程共用;失败自动恢复旧 Web Compose 配置。发布文件保留变量表达式,不写入生产秘密。健康检查通过后切换 current;原完整发布也能回滚到 Web 快速发布。 -首次接入流水线变更仍走完整验证;后续 Web 更新速度需要实测,不能承诺 HMR 的毫秒级反馈。每次发布完整 Web 产物,不把个别 chunk 混入正在运行的 Next.js 版本。 +首次接入流水线变更仍走完整验证。2026-10-23 workflow 291 实测:Web 构建与发布91秒,拉取代码44秒,合计2分15秒,不包含共享 Runner 排队。运行镜像复用,worker、Postgres、RustFS 容器 ID 均不变;编译缓存已持久化。不能承诺 HMR 的毫秒级反馈。每次发布完整 Web 产物,不把个别 chunk 混入正在运行的 Next.js 版本。 diff --git a/docs/guidance.md b/docs/guidance.md new file mode 100644 index 0000000..78ca9d7 --- /dev/null +++ b/docs/guidance.md @@ -0,0 +1,19 @@ +# 指导文档的两种呈现 + +指导文档共用一份内容。每篇都有独立网页 URL,后面加 `/index.md` 就是 Markdown 版本: + +| 网页 | Markdown | +| --- | --- | +| `/` | `/index.md` | +| `/skills` | `/skills/index.md` | +| `/skills/shadow-puppet` | `/skills/shadow-puppet/index.md` | +| `/skills/shadow-puppet/prompt` | `/skills/shadow-puppet/prompt/index.md` | +| `/skills/image-delivery` | `/skills/image-delivery/index.md` | + +这里的 skills 指指导文档,不要求使用外部技能包规范,也不要求先从某个 SKILL.md 进入。旧 `/agent`、`/agent/index.md`、`/tools/skills/...`、`/skills//SKILL.md` 和 `.md` 地址兼容保留。 + +`src/content/skills/guidance.ts` 是全局平面文档注册表。路径是文档的稳定标识;分组只用于浏览,不隔离引用。已有两套文档继续从 catalog 读取内容,注册表为它们生成成对 URL、共享资源引用和反向引用;新增独立文档直接注册唯一 path,无需归属某个技能。 + +网页正文的文档链接进入网页,Markdown 正文的文档链接进入 Markdown。图片和 API 继续使用原始资源地址。复制按钮复制当前这一篇的 Markdown 地址,每页 HTML head 也声明对应地址。反向引用从正文链接计算,两种呈现显示相同关系。 + +共用的 image-delivery 文档由 street-people/checks 和 shadow-puppet/delivery 引用,同时链接回各自的具体验收协议。它证明引用可以跨分组,不复制内容。 diff --git a/next.config.ts b/next.config.ts index 4608c3e..3428686 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { serverExternalPackages: ["playwright-core"], - async rewrites(){return [{source:"/agent",destination:"/api/agent-index"},{source:"/agent/index.md",destination:"/api/agent-index"},{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"}]}, + async rewrites(){return [ + {source:"/index.md",destination:"/api/guidance"}, + {source:"/skills/index.md",destination:"/api/guidance/skills"}, + {source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"}, + {source:"/agent",destination:"/api/agent-index"}, + {source:"/agent/index.md",destination:"/api/agent-index"}, + {source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"}, + ]}, output: "standalone", outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]}, poweredByHeader: false, diff --git a/public/llms.txt b/public/llms.txt index b5c1356..d72ede2 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,6 +1,9 @@ # PROMPT -> 素材与创作技能网站。 +> 创作素材与可相互引用的指导文档。 -- [Agent 默认入口](/agent/index.md): Markdown 技能索引与任务导航。 -- [人类入口](/): 网页浏览与素材管理。 +- [网站 Markdown 入口](/index.md) +- [全部指导文档](/skills/index.md) +- [人类入口](/) + +每篇指导文档的网页地址加 /index.md,即为一一对应的 Markdown。沿文档链接继续阅读,可直接进入任意一篇,不必经过某个技能入口。 diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 21158d4..d7072b9 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -31,7 +31,7 @@ export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?: useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]); const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags.filter(t=>target?t.parent_id===target.id:!t.parent_id); async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}} - return {target?.name||current?.name||'灵感首页'} · PROMPT + return {target?.name||current?.name||'灵感首页'} · PROMPT {current&&

{target?.name||current.name}

{current.hint}

} {!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};})}
} diff --git a/src/components/GuidanceIndex.tsx b/src/components/GuidanceIndex.tsx new file mode 100644 index 0000000..8af6d8a --- /dev/null +++ b/src/components/GuidanceIndex.tsx @@ -0,0 +1,12 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import {Shell} from './Shell'; +import {SkillCard} from './SkillCard'; +import {skillCatalog} from '@/content/skills/catalog'; +import {guidanceDocuments,humanDocumentUrl} from '@/content/skills/guidance'; +export function GuidanceIndex(){return 指导文档 · PROMPT +

指导文档

每篇都有独立地址,网页与 Markdown 一一对应。沿着引用,找到需要的方法。

+

全部文档

这些分组只帮助浏览。任何一篇都可以独立进入,也可以引用其他文档。

{guidanceDocuments.map(doc=>
{doc.title}{doc.collection}Markdown ↗
)}
+

按创作主题浏览

+
{skillCatalog.map(skill=>)}
+
} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 0537eed..5546ebc 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:'/tools/skills',label:'技能',icon:BookOpen},{href:'/tools/doodle',label:'线条工具',icon:Sparkles}]}, + {label:'工具',items:[{href:'/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/SkillDocs.tsx b/src/components/SkillDocs.tsx index a1b3398..6eb20f2 100644 --- a/src/components/SkillDocs.tsx +++ b/src/components/SkillDocs.tsx @@ -3,12 +3,46 @@ import Link from 'next/link'; import {useState,useEffect,useRef} from 'react'; import {X,PanelRight,Copy,ExternalLink} from 'lucide-react'; import {Shell} from './Shell'; -import {getSkill,skillDocumentMarkdown,type Resource} from '@/content/skills/catalog'; -export function SkillDocs({documentId='index',skillId='street-people'}:{documentId?:string;skillId?:string}){ - const skill=getSkill(skillId)!;const skillResources=skill.resources;const doc=skill.documents.find(d=>d.id===documentId)!;const [open,setOpen]=useState(false);const[selected,setSelected]=useState(null);const[copied,setCopied]=useState(false);const panel=useRef(null); +import {getGuidance,guidanceMarkdown,guidanceBacklinks,humanDocumentUrl,markdownDocumentUrl,legacyDocumentPath,type GuidanceResource} from '@/content/skills/guidance'; + +export function SkillDocs({documentPath,documentId='index',skillId='street-people'}:{documentPath?:string;documentId?:string;skillId?:string}){ + const path=documentPath??legacyDocumentPath(skillId,documentId); + const doc=getGuidance(path)!; + 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)},[skillId,documentId]); + useEffect(()=>{setSelected(null);setCopied(false)},[path]); 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.title} / 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&&}
; + const resource=(id:string)=>doc.resources.find(r=>r.id===id)!; + const incoming=guidanceBacklinks(path); + async function copy(){ + try{ + const value=new URL(markdownDocumentUrl(path),location.origin).href; + 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)} + } + return {doc.title} · 指导文档 · PROMPT +
← 全部指导文档
+
+
{doc.collection} / 指导文档

{doc.title}

+
本文 Markdown ↗
+

网页与 Markdown 来自同一份文档。每篇都有独立地址,可以直接进入,也可以沿引用继续阅读。

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

{b.text}

:b.type==='code'?
{b.text}
:b.type==='links'?
{b.refs!.map(id=>{ + const r=resource(id); + return r.kind==='document'?文档 · {r.title}:{e.preventDefault();setSelected(r);setOpen(true);panel.current?.focus()}}>{r.kind==='image'?'图片':r.method} · {r.title}; + })}
:

{b.text}

)} + {incoming.length>0&&<>

引用本文的文档

{incoming.map(d=>{d.title})}
} +
+ {open&&} +
+
; } diff --git a/src/content/skills/catalog.ts b/src/content/skills/catalog.ts index f44cf24..d7da263 100644 --- a/src/content/skills/catalog.ts +++ b/src/content/skills/catalog.ts @@ -29,7 +29,7 @@ export const skillDocuments:SkillDocument[]=[ {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'},{id:'shadow-puppet',title:'照片入皮影',category:'照片转译 · 3:4 海报',description:'把照片里的主角请上幕布,让它用一个姿态演完一整出戏。',image:'/skills/shadow-puppet/egret-example.png',alt:'白鹭照片与暖灯宣纸皮影的上下等分海报示例',href:'/tools/skills/shadow-puppet',created_at:'2026-10-23T03:45:00Z'}]; +export const skillCatalog=[{id:'street-people',title:'街头小人',category:'图片生成 · 透明 PNG',description:'松散的线条,冬天的衣着。把街上的一个瞬间,剪成一张小贴纸。',image:'/skills/street-people/sample.png',alt:'手绘街头小人,透明剪纸素材',href:'/skills/street-people',created_at:'2026-10-22T17:29:47Z'},{id:'shadow-puppet',title:'照片入皮影',category:'照片转译 · 3:4 海报',description:'把照片里的主角请上幕布,让它用一个姿态演完一整出戏。',image:'/skills/shadow-puppet/egret-example.png',alt:'白鹭照片与暖灯宣纸皮影的上下等分海报示例',href:'/skills/shadow-puppet',created_at:'2026-10-23T03:45:00Z'}]; export type SkillDefinition={id:string;title:string;documents:SkillDocument[];resources:Resource[]}; const definitions:Record={ 'street-people':{id:'street-people',title:'街头小人',documents:skillDocuments,resources:skillResources.map(r=>r.kind==='api'?{...r,detail:r.method==='POST'?streetPeople.upload:streetPeople,description:r.method==='POST'?'上传接口。阅读上传文档后,由 Agent 使用 multipart/form-data 提交文件。此处仅展示说明,不会发送请求。':'返回 JSON 规范。入口文档负责引导流程,JSON 用于查阅接口结构。'}:r)}, diff --git a/src/content/skills/guidance.ts b/src/content/skills/guidance.ts new file mode 100644 index 0000000..8e87d7a --- /dev/null +++ b/src/content/skills/guidance.ts @@ -0,0 +1,62 @@ +import {getSkill,skillCatalog,type Block,type Resource} from './catalog'; + +export type GuidanceResource=Resource&{documentPath?:string}; +export type GuidanceDocument={path:string;title:string;collection:string;blocks:Block[];resources:GuidanceResource[]}; +const origin='http://8.211.154.134:18131'; +export const humanDocumentUrl=(path:string)=>`/skills/${path}`; +export const markdownDocumentUrl=(path:string)=>`${humanDocumentUrl(path)}/index.md`; +export const legacyDocumentPath=(skill:string,doc:string)=>skill+(['index','SKILL'].includes(doc)?'':`/${doc}`); +export const documentResource=(path:string,title:string,id=`doc:${path}`):GuidanceResource=>({id,kind:'document',title,documentPath:path,href:markdownDocumentUrl(path),human:humanDocumentUrl(path)}); +const shared=documentResource('image-delivery','图像交付与来源记录'); + +// A flat document registry. Collection names organize browsing, never restrict references. +export const guidanceDocuments:GuidanceDocument[]=skillCatalog.flatMap(card=>{ + const skill=getSkill(card.id)!; + const resources:GuidanceResource[]=skill.resources.map(r=>{ + if(r.kind!=='document')return {...r,id:`${card.id}:${r.id}`}; + const legacy=r.href.match(/^\/skills\/([^/]+)\/([^/]+)\.md$/); + const path=r.href.startsWith('/skills/')&&r.href.endsWith('/index.md')?r.href.slice(8,-9) + :legacy?legacyDocumentPath(legacy[1],legacy[2]) + :r.human?.startsWith('/skills/')?r.human.slice(8):legacyDocumentPath(card.id,r.id); + const title=skill.documents.find(d=>legacyDocumentPath(card.id,d.id)===path)?.title??r.title; + return documentResource(path,title,`${card.id}:${r.id}`); + }); + return skill.documents.map(doc=>({ + path:legacyDocumentPath(card.id,doc.id),title:doc.title,collection:skill.title, + resources:[...resources,shared], + blocks:[...doc.blocks.map(b=>b.type==='links'?{...b,refs:b.refs!.map(id=>`${card.id}:${id}`)}:{...b}), + ...(['checks','delivery'].includes(doc.id)?[{type:'heading' as const,text:'共用指导文档'},{type:'links' as const,refs:[shared.id]}]:[])], + })); +}); +guidanceDocuments.push({ + path:'image-delivery',title:'图像交付与来源记录',collection:'共用指导', + resources:[documentResource('street-people/checks','透明人物验收'),documentResource('shadow-puppet/delivery','皮影海报验收与上传')], + blocks:[ + {type:'paragraph',text:'这是一份可被不同创作方法共同引用的指导文档。每篇文档都有独立网址,引用不会复制或隔离内容。'}, + {type:'heading',text:'交付前确认'}, + {type:'paragraph',text:'按照具体任务确认尺寸、比例、格式、透明背景与成品数量。保留原始生成文件;透明 PNG 与有背景的海报使用各自的验收规则。'}, + {type:'heading',text:'记录来源'}, + {type:'paragraph',text:'记录原片或参考图、使用的提示词、生成工具及必要的修改说明。来源不同的作品分开说明,便于复用时追溯。'}, + {type:'heading',text:'选择对应的交付协议'}, + {type:'paragraph',text:'阅读目标文档的上传说明再提交。不同接口的字段和校验条件可能不同,以被引用文档为准。'}, + {type:'links',refs:['doc:street-people/checks','doc:shadow-puppet/delivery']}, + ], +}); +const registry=new Map(guidanceDocuments.map(doc=>[doc.path,doc])); +export function getGuidance(path:string){return registry.get(path);} +export function guidanceBacklinks(path:string){return guidanceDocuments.filter(doc=>doc.path!==path&&doc.blocks.some(b=>b.type==='links'&&b.refs!.some(id=>doc.resources.find(r=>r.id===id)?.documentPath===path)));} +export function guidanceMarkdown(path:string,base=origin){ + const doc=getGuidance(path);if(!doc)return null; + const url=(href:string)=>new URL(href,base).href; + const blocks=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=doc.resources.find(r=>r.id===id)!;return `- [${r.title}](${url(r.href)})${r.method?' — '+r.method:''}`; + }).join('\n'):b.text).join('\n\n'); + const incoming=guidanceBacklinks(path); + return `# ${doc.title}\n\n[网页版本](${url(humanDocumentUrl(path))}) · [全部指导文档](${url('/skills/index.md')})\n\n${blocks}\n`+ + (incoming.length?`\n## 引用本文的文档\n\n${incoming.map(d=>`- [${d.title}](${url(markdownDocumentUrl(d.path))})`).join('\n')}\n`:''); +} +export function guidanceIndexMarkdown(root=false,base=origin){ + const url=(href:string)=>new URL(href,base).href; + return `# ${root?'PROMPT':'指导文档'}\n\n[网页版本](${url(root?'/':'/skills')}) · [指导文档目录](${url('/skills/index.md')})\n\n每篇指导文档都有独立网址,网页地址加 /index.md 即为对应的 Markdown。文档可以相互引用,不要求从某个技能入口逐级进入。\n\n## 全部指导文档\n\n`+ + guidanceDocuments.map(d=>`- [${d.title}](${url(markdownDocumentUrl(d.path))}) — ${d.collection}`).join('\n')+'\n'; +} diff --git a/src/lib/markdown-response.ts b/src/lib/markdown-response.ts new file mode 100644 index 0000000..e9bf7ac --- /dev/null +++ b/src/lib/markdown-response.ts @@ -0,0 +1,9 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +export function sendMarkdown(req:NextApiRequest,res:NextApiResponse,text:string|null){ + res.setHeader('Allow','GET, HEAD'); + if(req.method!=='GET'&&req.method!=='HEAD')return res.status(405).end(); + if(text===null)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.status(200).end():res.status(200).send(text); +} diff --git a/src/pages/api/agent-index.ts b/src/pages/api/agent-index.ts index 180fa3f..989a45c 100644 --- a/src/pages/api/agent-index.ts +++ b/src/pages/api/agent-index.ts @@ -1,28 +1,4 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { skillCatalog } from "@/content/skills/catalog"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "GET" && req.method !== "HEAD") { - res.setHeader("Allow", "GET, HEAD"); - return res.status(405).end(); - } - const markdown = [ - "# PROMPT — Agent 入口", - "", - "人类入口:[/](/)。Agent 默认入口:[/agent/index.md](/agent/index.md)。", - "", - "按任务选择技能,先阅读 SKILL.md,再按其中链接读取子文档、参考图片与上传协议。所有相对链接均以当前网站根地址解析。", - "不要从 HTML 页面猜测上传协议;生成由你自己的工具完成,网站提供文档、校验与素材存储。执行上传前遵循用户授权和对应技能的要求。", - "", - "## 技能目录", - ...skillCatalog.map(skill => `- [${skill.title}](/skills/${skill.id}/SKILL.md) — ${skill.description}`), - "", - "## 网站导航", - "- [人类首页](/)", - "- [技能浏览](/tools/skills)", - "", - ].join("\n"); - res.setHeader("Content-Type", "text/markdown; charset=utf-8"); - res.setHeader("Cache-Control", "no-cache"); - return req.method === "HEAD" ? res.status(200).end() : res.status(200).send(markdown); -} +import type {NextApiRequest,NextApiResponse} from 'next'; +import {guidanceIndexMarkdown} from '@/content/skills/guidance'; +import {sendMarkdown} from '@/lib/markdown-response'; +export default function handler(req:NextApiRequest,res:NextApiResponse){return sendMarkdown(req,res,guidanceIndexMarkdown(true));} diff --git a/src/pages/api/guidance/[[...path]].ts b/src/pages/api/guidance/[[...path]].ts new file mode 100644 index 0000000..45f316a --- /dev/null +++ b/src/pages/api/guidance/[[...path]].ts @@ -0,0 +1,8 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {guidanceIndexMarkdown,guidanceMarkdown} from '@/content/skills/guidance'; +import {sendMarkdown} from '@/lib/markdown-response'; +export default function handler(req:NextApiRequest,res:NextApiResponse){ + const parts=Array.isArray(req.query.path)?req.query.path:[]; + const text=parts.length===0?guidanceIndexMarkdown(true):parts[0]!=='skills'?null:parts.length===1?guidanceIndexMarkdown():guidanceMarkdown(parts.slice(1).join('/')); + return sendMarkdown(req,res,text); +} diff --git a/src/pages/api/skill-docs/[skill]/[doc].ts b/src/pages/api/skill-docs/[skill]/[doc].ts index 758230f..69d6ee4 100644 --- a/src/pages/api/skill-docs/[skill]/[doc].ts +++ b/src/pages/api/skill-docs/[skill]/[doc].ts @@ -1,11 +1,7 @@ import type {NextApiRequest,NextApiResponse} from 'next'; -import {skillDocumentMarkdown} from '@/content/skills/catalog'; +import {guidanceMarkdown,legacyDocumentPath} from '@/content/skills/guidance'; +import {sendMarkdown} from '@/lib/markdown-response'; export default function handler(req:NextApiRequest,res:NextApiResponse){ - res.setHeader('Allow','GET, HEAD'); - if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end(); const name=String(req.query.doc).replace(/\.md$/,''); - const text=skillDocumentMarkdown(String(req.query.skill),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.status(200).end():res.status(200).send(text); + return sendMarkdown(req,res,guidanceMarkdown(legacyDocumentPath(String(req.query.skill),name))); } diff --git a/src/pages/api/skill-docs/street-people/[doc].ts b/src/pages/api/skill-docs/street-people/[doc].ts index 394739a..7c35c42 100644 --- a/src/pages/api/skill-docs/street-people/[doc].ts +++ b/src/pages/api/skill-docs/street-people/[doc].ts @@ -1,3 +1,4 @@ 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)} +import {guidanceMarkdown,legacyDocumentPath} from '@/content/skills/guidance'; +import {sendMarkdown} from '@/lib/markdown-response'; +export default function handler(req:NextApiRequest,res:NextApiResponse){return sendMarkdown(req,res,guidanceMarkdown(legacyDocumentPath('street-people',String(req.query.doc).replace(/\.md$/,''))));} diff --git a/src/pages/api/skills/street-people.ts b/src/pages/api/skills/street-people.ts index da88efc..1bbe1d7 100644 --- a/src/pages/api/skills/street-people.ts +++ b/src/pages/api/skills/street-people.ts @@ -1,14 +1,14 @@ import type {NextApiRequest,NextApiResponse} from "next"; import {upload} from "../upload"; import {streetPeople} from "@/lib/street-people"; -import {documentMarkdown} from "@/content/skills/catalog"; +import {guidanceMarkdown} from "@/content/skills/guidance"; 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(documentMarkdown("index")); + if(req.query.format==="md")return res.setHeader("Content-Type","text/markdown; charset=utf-8").status(200).send(guidanceMarkdown("street-people")); return res.status(200).json(streetPeople); } if(req.method==="POST")return upload(req,res,{validateImage:validateCharacter,defaultTitle:"街头小人 · 新生成"}); diff --git a/src/pages/skills/[[...path]].tsx b/src/pages/skills/[[...path]].tsx new file mode 100644 index 0000000..2bab61e --- /dev/null +++ b/src/pages/skills/[[...path]].tsx @@ -0,0 +1,10 @@ +import type {GetServerSideProps} from 'next'; +import {SkillDocs} from '@/components/SkillDocs'; +import {GuidanceIndex} from '@/components/GuidanceIndex'; +import {getGuidance} from '@/content/skills/guidance'; +export default function GuidancePage({documentPath}:{documentPath:string|null}){return documentPath===null?:;} +export const getServerSideProps:GetServerSideProps=async({params})=>{ + const parts=params?.path;if(parts===undefined)return {props:{documentPath:null}}; + if(!Array.isArray(parts))return {notFound:true}; + const documentPath=parts.join('/');return getGuidance(documentPath)?{props:{documentPath}}:{notFound:true}; +}; diff --git a/src/pages/tools/skills/index.tsx b/src/pages/tools/skills/index.tsx index 8ea79d8..5ae4c6f 100644 --- a/src/pages/tools/skills/index.tsx +++ b/src/pages/tools/skills/index.tsx @@ -1,5 +1 @@ -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 技能 · PROMPT

技能

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

{skillCatalog.map(skill=>)}
} +export {GuidanceIndex as default} from '@/components/GuidanceIndex'; diff --git a/src/styles/skill-docs.css b/src/styles/skill-docs.css index d37bb89..6042d83 100644 --- a/src/styles/skill-docs.css +++ b/src/styles/skill-docs.css @@ -1,2 +1,3 @@ .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}} +.guidance-directory{border-top:1px solid #e4e4df;margin-top:24px}.guidance-directory>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 0;border-bottom:1px solid #e4e4df}.guidance-directory>div>a:first-child{font-size:15px;color:#3e4938}.guidance-directory small,.guidance-directory>div>a:last-child{font-size:12px;color:#888}@media(max-width:760px){.guidance-directory>div{grid-template-columns:minmax(0,1fr) auto;gap:8px}.guidance-directory small{grid-column:1;grid-row:2}.guidance-directory>div>a:last-child{grid-column:2;grid-row:1/3}} diff --git a/tests/skill-docs.test.ts b/tests/skill-docs.test.ts index 9182b2e..026a655 100644 --- a/tests/skill-docs.test.ts +++ b/tests/skill-docs.test.ts @@ -7,7 +7,7 @@ test('human and agent documents share a complete linked resource registry',()=>{ for(const doc of skillDocuments)assert.ok(skillResources.some(r=>r.id===doc.id&&r.human&&r.href.endsWith('.md'))); }); -test('each registered skill resolves its own entry, documents and API resources without leaking another skill',()=>{ +test('original collections retain their authored documents and API resources',()=>{ for(const card of skillCatalog){ const skill=getSkill(card.id)!;assert.ok(skill);assert.equal(skill.id,card.id); assert.equal(new Set(skill.resources.map(r=>r.id)).size,skill.resources.length); @@ -23,6 +23,37 @@ test('each registered skill resolves its own entry, documents and API resources assert.equal(shadow.resources.filter(r=>r.kind==='image').length,2); assert.equal(shadow.resources.find(r=>r.id==='upload-api')?.href,'/api/upload'); assert.equal(shadow.resources.find(r=>r.id==='media-api')?.template,true); - assert.ok(!skillDocumentMarkdown('shadow-puppet','index')!.includes('/skills/street-people/')); - assert.ok(!documentMarkdown('index')!.includes('/skills/shadow-puppet/')); +}); + +test('every guidance page has a matching Markdown URL and all document links resolve globally',async()=>{ + const {guidanceDocuments,getGuidance,guidanceMarkdown,guidanceIndexMarkdown,humanDocumentUrl,markdownDocumentUrl,guidanceBacklinks}=await import('../src/content/skills/guidance'); + assert.equal(new Set(guidanceDocuments.map(d=>d.path)).size,guidanceDocuments.length); + for(const doc of guidanceDocuments){ + for(const r of doc.resources)if(r.documentPath)assert.ok(getGuidance(r.documentPath),r.documentPath); + const text=guidanceMarkdown(doc.path)!; + assert.ok(text.startsWith('# '+doc.title)); + assert.equal(markdownDocumentUrl(doc.path),humanDocumentUrl(doc.path)+'/index.md'); + assert.ok(text.includes(humanDocumentUrl(doc.path))); + for(const block of doc.blocks){ + if(block.text)assert.ok(text.includes(block.text)); + for(const id of block.refs||[]){const r=doc.resources.find(r=>r.id===id);assert.ok(r,doc.path+': '+id);if(r.documentPath){assert.ok(getGuidance(r.documentPath));assert.ok(text.includes(markdownDocumentUrl(r.documentPath)));}} + } + assert.ok(guidanceIndexMarkdown().includes(markdownDocumentUrl(doc.path))); + } + assert.ok(guidanceIndexMarkdown(true).includes('/skills/index.md')); + assert.equal(getGuidance('__proto__'),undefined);assert.equal(guidanceMarkdown('missing'),null); + const shared=guidanceBacklinks('image-delivery').map(d=>d.path); + assert.ok(shared.includes('street-people/checks'));assert.ok(shared.includes('shadow-puppet/delivery')); + assert.ok(guidanceMarkdown('image-delivery')!.includes('/skills/street-people/checks/index.md')); + assert.ok(guidanceMarkdown('image-delivery')!.includes('/skills/shadow-puppet/delivery/index.md')); +}); + +test('Markdown counterpart responses support GET and HEAD and reject writes or missing documents',async()=>{ + const {sendMarkdown}=await import('../src/lib/markdown-response'); + const {guidanceMarkdown}=await import('../src/content/skills/guidance'); + for(const [method,text,expected] of [['GET',guidanceMarkdown('shadow-puppet/prompt'),200],['HEAD',guidanceMarkdown('shadow-puppet/prompt'),200],['POST',guidanceMarkdown('shadow-puppet/prompt'),405],['GET',guidanceMarkdown('missing'),404]] as const){ + const result={code:0,body:null as string|null,headers:{} as Record,setHeader(k:string,v:string){this.headers[k]=v;return this},status(code:number){this.code=code;return this},send(body:string){this.body=body;return this},end(){return this}}; + sendMarkdown({method} as import('next').NextApiRequest,result as unknown as import('next').NextApiResponse,text); + assert.equal(result.code,expected);if(method==='GET'&&expected===200){assert.match(result.headers['Content-Type'],/text\/markdown/);assert.ok(result.body?.startsWith('# '));}else assert.equal(result.body,null); + } });