From b943951bedf2df00aa26fee00c2cad338e224e8e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 25 Oct 2026 22:06:05 +0800 Subject: [PATCH] Unify content under ID-based note dialogs with lightweight previews and Studio proxy --- Dockerfile | 6 ++- apps/studio/Dockerfile | 6 ++- apps/studio/next.config.ts | 2 +- apps/studio/src/pages/api/index.ts | 2 +- apps/studio/src/pages/ideas/[slug].tsx | 2 +- apps/studio/src/pages/index.tsx | 2 +- apps/wiki/Dockerfile | 6 ++- apps/wiki/next.config.ts | 1 + apps/wiki/package.json | 2 +- apps/wiki/scripts/smoke.mjs | 8 ++++ apps/wiki/src/components/ArticleGallery.tsx | 3 +- apps/wiki/src/components/ExploreDetail.tsx | 14 ++++++ apps/wiki/src/components/ExploreFeed.tsx | 5 +++ apps/wiki/src/components/Feed.tsx | 5 ++- apps/wiki/src/components/Shell.tsx | 11 ++--- apps/wiki/src/components/SkillCard.tsx | 4 +- apps/wiki/src/components/SkillDocs.tsx | 3 +- apps/wiki/src/content/page-ids.json | 16 +++++++ apps/wiki/src/content/page-links.ts | 6 +++ apps/wiki/src/content/skills/guidance.ts | 5 ++- apps/wiki/src/lib/explore.ts | 23 ++++++++++ apps/wiki/src/lib/library.ts | 4 ++ apps/wiki/src/lib/preview.ts | 14 ++++++ apps/wiki/src/pages/api/explore-feed.ts | 3 ++ apps/wiki/src/pages/api/preview.ts | 6 +++ apps/wiki/src/pages/api/wiki-index.ts | 5 ++- apps/wiki/src/pages/categories.tsx | 9 ++++ apps/wiki/src/pages/explore/[id].tsx | 5 +++ apps/wiki/src/pages/index.tsx | 7 +-- apps/wiki/src/pages/p/[slug].tsx | 9 ++-- apps/wiki/src/pages/skills/[[...path]].tsx | 11 ++--- .../pages/tools/skills/[skill]/[[...doc]].tsx | 14 ++---- .../src/pages/tools/skills/street-people.tsx | 5 ++- .../tools/skills/street-people/[doc].tsx | 8 ++-- apps/wiki/src/pages/wiki/[key].tsx | 14 +++--- apps/wiki/src/styles/globals.css | 4 ++ apps/wiki/tests/explore.test.ts | 9 ++++ apps/wiki/tests/integration.test.ts | 2 +- apps/wiki/tests/preview.test.ts | 7 +++ apps/wiki/tests/skill-docs.test.ts | 6 +-- docs/explore-stage-files.md | 45 +++++++++++++++++++ docs/monorepo.md | 10 +++++ pnpm-workspace.yaml | 2 + scripts/deploy-studio.sh | 9 ++-- 44 files changed, 265 insertions(+), 75 deletions(-) create mode 100644 apps/wiki/src/components/ExploreDetail.tsx create mode 100644 apps/wiki/src/components/ExploreFeed.tsx create mode 100644 apps/wiki/src/content/page-ids.json create mode 100644 apps/wiki/src/content/page-links.ts create mode 100644 apps/wiki/src/lib/explore.ts create mode 100644 apps/wiki/src/lib/preview.ts create mode 100644 apps/wiki/src/pages/api/explore-feed.ts create mode 100644 apps/wiki/src/pages/api/preview.ts create mode 100644 apps/wiki/src/pages/categories.tsx create mode 100644 apps/wiki/src/pages/explore/[id].tsx create mode 100644 apps/wiki/tests/explore.test.ts create mode 100644 apps/wiki/tests/preview.test.ts create mode 100644 docs/explore-stage-files.md diff --git a/Dockerfile b/Dockerfile index fb96fb8..a4ed8ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,12 @@ RUN apt-get update \ FROM base AS dependencies RUN corepack enable && corepack prepare pnpm@11.25.0 --activate -COPY apps/wiki/package.json apps/wiki/pnpm-lock.yaml ./ +COPY apps/wiki/pnpm-lock.yaml ./ COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));" -RUN pnpm install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +COPY apps/wiki/package.json ./ +RUN pnpm install --offline --frozen-lockfile FROM dependencies AS build # .dockerignore excludes docs and operational notes without extra legacy COPY snapshots. diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 9d85af8..818e1b0 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -1,10 +1,12 @@ FROM node:24-bookworm-slim AS build WORKDIR /app RUN corepack enable && corepack prepare pnpm@11.25.0 --activate -COPY apps/studio/package.json apps/studio/pnpm-lock.yaml ./ +COPY apps/studio/pnpm-lock.yaml ./ COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));" -RUN pnpm install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +COPY apps/studio/package.json ./ +RUN pnpm install --offline --frozen-lockfile COPY apps/studio/ . RUN pnpm typecheck && pnpm build FROM node:24-bookworm-slim diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 8f5d18f..1e9cf49 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -1,3 +1,3 @@ import type { NextConfig } from "next"; -const config: NextConfig = { output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false }; +const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false }; export default config; diff --git a/apps/studio/src/pages/api/index.ts b/apps/studio/src/pages/api/index.ts index de07d5f..bb8e209 100644 --- a/apps/studio/src/pages/api/index.ts +++ b/apps/studio/src/pages/api/index.ts @@ -1,3 +1,3 @@ import type {NextApiRequest,NextApiResponse} from 'next'; import {ideas} from '../../content/ideas'; -export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n'+ideas.map(i=>`- [${i.title}](/ideas/${i.slug}) · [正文](/api/export/${i.slug}?format=markdown)`).join('\n'));} +export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));} diff --git a/apps/studio/src/pages/ideas/[slug].tsx b/apps/studio/src/pages/ideas/[slug].tsx index 26afce1..d9d2c66 100644 --- a/apps/studio/src/pages/ideas/[slug].tsx +++ b/apps/studio/src/pages/ideas/[slug].tsx @@ -2,6 +2,6 @@ import type {GetStaticPaths,GetStaticProps} from 'next'; import Head from 'next/head'; import Link from 'next/link'; import {ideas,type Idea} from '../../content/ideas'; -export default function IdeaPage({idea}:{idea:Idea}){return
{idea.title} · 创作站
← 我的创作导出内容包 ↓Markdown ↓

{idea.title}

{idea.summary}

{idea.slides.map((s,i)=>
PROMPT STUDIO / {String(i+1).padStart(2,'0')}

{s.heading}

{s.body}

记录灵感 · 自由表达
)}
} +export default function IdeaPage({idea}:{idea:Idea}){return
{idea.title} · 创作站
← 我的创作导出内容包 ↓Markdown ↓

{idea.title}

{idea.summary}

{idea.slides.map((s,i)=>
PROMPT STUDIO / {String(i+1).padStart(2,'0')}

{s.heading}

{s.body}

记录灵感 · 自由表达
)}
} export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false}); export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}}; diff --git a/apps/studio/src/pages/index.tsx b/apps/studio/src/pages/index.tsx index 3ee6974..41d655e 100644 --- a/apps/studio/src/pages/index.tsx +++ b/apps/studio/src/pages/index.tsx @@ -1,3 +1,3 @@ import Link from 'next/link'; import {ideas} from '../content/ideas'; -export default function Home(){return
YOUR CREATIVE WORKSPACE

今天,想表达什么?

一个页面,装下一个想法。让内容慢慢长成作品。

{ideas.map(i=>
{i.title}

{i.title}

{i.summary}

打开创作 →)}

新增作品:在 src/content/ideas.ts 添加内容,页面与导出会同步生成。

} +export default function Home(){return
YOUR CREATIVE WORKSPACE

今天,想表达什么?

一个页面,装下一个想法。让内容慢慢长成作品。

{ideas.map(i=>
{i.title}

{i.title}

{i.summary}

打开创作 →)}

新增作品:在 src/content/ideas.ts 添加内容,页面与导出会同步生成。

} diff --git a/apps/wiki/Dockerfile b/apps/wiki/Dockerfile index fb96fb8..a4ed8ce 100644 --- a/apps/wiki/Dockerfile +++ b/apps/wiki/Dockerfile @@ -7,10 +7,12 @@ RUN apt-get update \ FROM base AS dependencies RUN corepack enable && corepack prepare pnpm@11.25.0 --activate -COPY apps/wiki/package.json apps/wiki/pnpm-lock.yaml ./ +COPY apps/wiki/pnpm-lock.yaml ./ COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));" -RUN pnpm install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 +COPY apps/wiki/package.json ./ +RUN pnpm install --offline --frozen-lockfile FROM dependencies AS build # .dockerignore excludes docs and operational notes without extra legacy COPY snapshots. diff --git a/apps/wiki/next.config.ts b/apps/wiki/next.config.ts index 6e39365..0589195 100644 --- a/apps/wiki/next.config.ts +++ b/apps/wiki/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { serverExternalPackages: ["playwright-core"], async rewrites(){return [ + {source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"}, {source:"/index.md",destination:"/api/guidance"}, {source:"/skills/index.md",destination:"/api/guidance/skills"}, {source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"}, diff --git a/apps/wiki/package.json b/apps/wiki/package.json index 484a438..ace9455 100644 --- a/apps/wiki/package.json +++ b/apps/wiki/package.json @@ -10,7 +10,7 @@ "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 tests/skill-docs.test.ts tests/wiki.test.ts", + "test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", diff --git a/apps/wiki/scripts/smoke.mjs b/apps/wiki/scripts/smoke.mjs index 99b1ef3..1059d43 100644 --- a/apps/wiki/scripts/smoke.mjs +++ b/apps/wiki/scripts/smoke.mjs @@ -34,6 +34,14 @@ try { assert.ok(ready, "Standalone production server must be healthy"); const response = await fetch(`http://127.0.0.1:${port}/api/health`, { method: "POST" }); assert.equal(response.status, 405); + const indexResponse=await fetch(`http://127.0.0.1:${port}/api/wiki-index`); + assert.equal(indexResponse.status,200); + const indexText=await indexResponse.text(); + const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id); + const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`); + assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);assert.ok(!(await agent.text()).includes('{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 })}
; + 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/apps/wiki/src/components/ExploreDetail.tsx b/apps/wiki/src/components/ExploreDetail.tsx new file mode 100644 index 0000000..0ee5af2 --- /dev/null +++ b/apps/wiki/src/components/ExploreDetail.tsx @@ -0,0 +1,14 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import {useEffect,useRef,useState} from 'react'; +import {useRouter} from 'next/router'; +import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText} from 'lucide-react'; +import type {ExploreEntry} from '@/lib/explore'; +import {previewUrl,exploreUrl} from '@/content/page-links'; +import {Shell} from './Shell'; +import {ExploreFeed} from './ExploreFeed'; +function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return
{part.replace(/^```[^\n]*\n?|```$/g,'')}
;if(part.startsWith('## '))return

{part.slice(3)}

;return

{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?{m[1]}:piece})}

})}} +export function ExploreDetail({entry,background}:{entry:ExploreEntry;background:ExploreEntry[]}){const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();const closeRef=useRef(null);const detailRef=useRef(null);const media=entry.media[index];const close=()=>void router.push('/');useEffect(()=>{const previous=document.activeElement as HTMLElement|null;closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';function key(e:KeyboardEvent){if(e.key==='Escape')close();if(e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));if(e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));if(e.key==='Tab'){const items=detailRef.current?.querySelectorAll('a[href],button:not([disabled]),video,audio');if(!items?.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}}window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};},[entry.id]);return {entry.title} · PROMPT
e.stopPropagation()}>
+{media?.kind==='image'?{media.title}:media?.kind==='video'?
P
PROMPT{entry.namespace} · 灵感百科
查看分类

{entry.title}

{entry.categories.map(c=>#{c} )}
内容 ID · {entry.id}
纯文本{media&&原文件 ↗}
} diff --git a/apps/wiki/src/components/ExploreFeed.tsx b/apps/wiki/src/components/ExploreFeed.tsx new file mode 100644 index 0000000..e691586 --- /dev/null +++ b/apps/wiki/src/components/ExploreFeed.tsx @@ -0,0 +1,5 @@ +import Link from 'next/link'; +import {useState} from 'react'; +import type {ExploreEntry} from '@/lib/explore'; +import {exploreUrl,previewUrl} from '@/content/page-links'; +export function ExploreFeed({initial,cursor:initialCursor=null,q='',category=''}:{initial:ExploreEntry[];cursor?:string|null;q?:string;category?:string}){const [entries,setEntries]=useState(initial);const [cursor,setCursor]=useState(initialCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');async function more(){setBusy(true);try{const res=await fetch('/api/explore-feed?'+new URLSearchParams({q,category,cursor:cursor||''}));if(!res.ok)throw Error();const data=await res.json();setEntries(old=>[...old,...data.entries.filter((e:ExploreEntry)=>!old.some(o=>o.id===e.id))]);setCursor(data.nextCursor);setError('');}catch{setError('加载失败,点击重试')}finally{setBusy(false)}}return <>
{entries.map((entry,i)=>
{entry.media[0]?{entry.media[0].title}:<>{entry.namespace.toUpperCase()}{entry.title}记录 · 连接 · 灵感}

{entry.title}

P{entry.categories[0]||entry.namespace}♡
)}
{!entries.length&&

这个分类还没有内容。

}{cursor&&
}} diff --git a/apps/wiki/src/components/Feed.tsx b/apps/wiki/src/components/Feed.tsx index ad8e3f7..b55cd52 100644 --- a/apps/wiki/src/components/Feed.tsx +++ b/apps/wiki/src/components/Feed.tsx @@ -1,3 +1,4 @@ +import {previewUrl} from '@/content/page-links'; import {SkillCard} from "./SkillCard"; import {skillCatalog} from "@/content/skills/catalog"; import Head from "next/head"; @@ -11,8 +12,8 @@ export function PostCard({post}:{post:Post}){ const image=post.assets.find(a=>a.kind==='image');const video=post.assets.find(a=>a.kind==='video');const cover=post.cover_asset_id||image?.id; const label=sections.find(s=>s.kind===post.kind)?.name;const count=post.assets.filter(a=>a.kind!=='text').length; const source=cover?mediaUrl(cover):video?.variants.includes('poster')?mediaUrl(video.id,'poster'):null; - return
- {source?{post.title}:post.kind==='audio'?
{Array.from({length:36},(_,i)=>)}
:
{post.kind==='video'?:}{label}
} + return
+ {source?{post.title}:post.kind==='audio'?
{Array.from({length:36},(_,i)=>)}
:
{post.kind==='video'?:}{label}
} {count>1&&{count}}{video&&{video.has_audio?'有声音':'无声音'}}

{post.title}

{label}
; } diff --git a/apps/wiki/src/components/Shell.tsx b/apps/wiki/src/components/Shell.tsx index ad1a015..3be8cc2 100644 --- a/apps/wiki/src/components/Shell.tsx +++ b/apps/wiki/src/components/Shell.tsx @@ -2,19 +2,14 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Grid2X2,Image,Film,AudioLines,BookOpen,Clapperboard,Sparkles,ArrowUpRight,Layers,Tags,Upload,Menu,X,Search } from "lucide-react"; import { useEffect,useState,type ReactNode } from "react"; -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:'/skills',label:'指导文档',icon:BookOpen},{href:'/tools/doodle',label:'线条工具',icon:Sparkles}]}, -]; +const groups=[{label:'',items:[{href:'/',label:'首页',icon:Grid2X2},{href:'/categories',label:'分类',icon:Tags}]}]; export function Shell({children}:{children:ReactNode}){ const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||'')); useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]); return
{e.preventDefault();const path=router.asPath.split('?')[0];const destination=/^\/(images|videos|audio|articles|motion)(\/|$)/.test(path)?path:'/';void router.push({pathname:destination,query:query?{q:query}:{}});}}>setQuery(e.target.value)}/>{query&&}
{children}
{open&&}
{children}{open&&
; } diff --git a/apps/wiki/src/components/SkillCard.tsx b/apps/wiki/src/components/SkillCard.tsx index 83cffcf..b2a02e0 100644 --- a/apps/wiki/src/components/SkillCard.tsx +++ b/apps/wiki/src/components/SkillCard.tsx @@ -1,4 +1,6 @@ +import {guidanceUrl} from '@/content/page-links'; +import {previewUrl} from '@/content/page-links'; import Link from 'next/link'; import {ArrowUpRight} from 'lucide-react'; import {skillCatalog} from '@/content/skills/catalog'; -export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return
{skill.alt}

{skill.title}

{skill.category}
} +export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return
{skill.alt}

{skill.title}

{skill.category}
} diff --git a/apps/wiki/src/components/SkillDocs.tsx b/apps/wiki/src/components/SkillDocs.tsx index 6eb20f2..1a52684 100644 --- a/apps/wiki/src/components/SkillDocs.tsx +++ b/apps/wiki/src/components/SkillDocs.tsx @@ -1,3 +1,4 @@ +import {previewUrl} from '@/content/page-links'; import Head from 'next/head'; import Link from 'next/link'; import {useState,useEffect,useRef} from 'react'; @@ -39,7 +40,7 @@ export function SkillDocs({documentPath,documentId='index',skillId='street-peopl {open&&} diff --git a/apps/wiki/src/content/page-ids.json b/apps/wiki/src/content/page-ids.json new file mode 100644 index 0000000..781b809 --- /dev/null +++ b/apps/wiki/src/content/page-ids.json @@ -0,0 +1,16 @@ +{ + "guidance:street-people": "4ce3283fd98bb03a8fb39a19", + "guidance:street-people/style": "a1207913bf7a7907488d3e69", + "guidance:street-people/upload": "42c414e58ff3b5f80919c2e2", + "guidance:street-people/checks": "7e80ad25076db9178fd6cff7", + "guidance:shadow-puppet": "3c98946754c33309d73d5284", + "guidance:shadow-puppet/workflow": "00837d41ae6b42abbdb62d58", + "guidance:shadow-puppet/visual-grammar": "d64c533a694331948f09b0a2", + "guidance:shadow-puppet/prompt": "9eb8a684f3356d66c0d9162c", + "guidance:shadow-puppet/delivery": "f64332ee01b13e28062f624c", + "guidance:image-delivery": "c8cf8ffbb1c4d4807ae82258", + "wiki:Main:welcome": "54d12ae51429f66fa7991ef5", + "wiki:Help:namespaces": "1a33340838520492b2e095c0", + "wiki:Template:idea": "40e0e456e2f5943610fef538", + "wiki:Draft:inbox": "a599a5734d13ec81fed1ffd1" +} diff --git a/apps/wiki/src/content/page-links.ts b/apps/wiki/src/content/page-links.ts new file mode 100644 index 0000000..77d0e49 --- /dev/null +++ b/apps/wiki/src/content/page-links.ts @@ -0,0 +1,6 @@ +import ids from './page-ids.json'; +// IDs are persisted, never derive them from titles or positions. Keep IDs when renaming a page. +export function pageId(key:string):string{return (ids as Record)[key]||'';} +export const exploreUrl=(id:string)=>'/explore/'+encodeURIComponent(id); +export const guidanceUrl=(path:string)=>exploreUrl(pageId('guidance:'+path)); +export function previewUrl(source:string,width=640){return '/api/preview?'+new URLSearchParams({source,width:String(width)});} diff --git a/apps/wiki/src/content/skills/guidance.ts b/apps/wiki/src/content/skills/guidance.ts index 8e87d7a..ed8c258 100644 --- a/apps/wiki/src/content/skills/guidance.ts +++ b/apps/wiki/src/content/skills/guidance.ts @@ -1,10 +1,11 @@ +import {guidanceUrl} from '../page-links'; 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 humanDocumentUrl=(path:string)=>guidanceUrl(path); +export const markdownDocumentUrl=(path:string)=>`${humanDocumentUrl(path)}?agent_mode=True`; 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','图像交付与来源记录'); diff --git a/apps/wiki/src/lib/explore.ts b/apps/wiki/src/lib/explore.ts new file mode 100644 index 0000000..0780abf --- /dev/null +++ b/apps/wiki/src/lib/explore.ts @@ -0,0 +1,23 @@ +import {guidanceDocuments,guidanceMarkdown} from '@/content/skills/guidance'; +import {wikiPages,wikiMarkdown} from '@/content/wiki'; +import {skillCatalog} from '@/content/skills/catalog'; +import {pageId,exploreUrl,guidanceUrl} from '@/content/page-links'; +import {getPostById,feed} from './library'; +import {snapshotState} from './snapshots'; +import {chapters} from '@/content/learning-skills/data'; +export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string}; +export type ExploreEntry={id:string;title:string;summary:string;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string}; +export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,summary:doc.blocks.find(b=>b.type==='paragraph')?.text||'',namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:card?[{kind:'image' as const,url:card.image,title:card.alt}]:doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title})),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,summary:page.summary,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))]; +// Canonicalize document references, including absolute links; pictures remain explicit links. +export function canonicalMarkdown(text:string){return text.replace(/\]\((?:http:\/\/8\.211\.154\.134:18131)?\/skills\/([^ )]+)\)/g,(whole,path:string)=>{const clean=path.replace(/\/index\.md$/,'').replace(/\.md$/,'');return pageId('guidance:'+clean)?']('+guidanceUrl(clean)+'?agent_mode=True)':whole;});} +export async function getEntry(id:string):Promise{ + const page=staticEntries.find(e=>e.id===id);if(page)return {...page,markdown:canonicalMarkdown(page.markdown)}; + if(!/^[0-9a-f-]{36}$/.test(id))return null; + const post=await getPostById(id);if(!post)return null; + let media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+a.id+'?variant=poster'}:{})})); + let body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n'); + if(post.page_key){const chapter=chapters.find(c=>c.key===post.page_key);if(chapter)body+='\n\n'+chapter.subtitle+'\n\n'+chapter.skills.map(skill=>'## '+skill.title+'\n\n'+skill.note+'\n\n输入:'+skill.input+'\n输出:'+skill.output+'\n\n'+skill.prompt+'\n\n[来源]('+skill.source+')').join('\n\n');const state=await snapshotState(post.slug);if(state?.pages.length)media=state.pages.map(p=>({kind:'image' as const,url:'/api/media/'+p.asset_id,title:'图文第 '+p.page_index+' 页'}));} + return {id:post.id,title:post.title,summary:body.slice(0,180),namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug}; +} +export async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.summary).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,summary:post.assets.find(a=>a.kind==='text')?.body||'',namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+video.id+'?variant=poster',title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};} +export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';} diff --git a/apps/wiki/src/lib/library.ts b/apps/wiki/src/lib/library.ts index 4e44922..946ac36 100644 --- a/apps/wiki/src/lib/library.ts +++ b/apps/wiki/src/lib/library.ts @@ -13,6 +13,10 @@ export async function getPost(slug: string): Promise { const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]); return serialize(result.rows,await tags())[0] || null; } +export async function getPostById(id:string):Promise{ + const result=await db().query(`${expand} WHERE p.id=$1`,[id]); + return serialize(result.rows,await tags())[0]||null; +} export async function feed(options: {kind?: PostKind; path?: string; cursor?: string; q?: string} = {}): Promise { const allTags=await tags(); const target=options.path ? allTags.find(t=>t.path===options.path) : undefined; if(options.path&&!target) throw new Error("Tag not found"); diff --git a/apps/wiki/src/lib/preview.ts b/apps/wiki/src/lib/preview.ts new file mode 100644 index 0000000..801195e --- /dev/null +++ b/apps/wiki/src/lib/preview.ts @@ -0,0 +1,14 @@ +import sharp from 'sharp'; +import {createHash} from 'node:crypto'; +import {mkdir,readFile,writeFile,rename,stat,readdir,unlink,realpath} from 'node:fs/promises'; +import {resolve,sep,join} from 'node:path'; +import {tmpdir} from 'node:os'; +export const previewWidths=[240,480,960,1280]; +export function previewWidth(value:unknown){const n=Number(value||480);return previewWidths.includes(n)?n:null;} +export function staticPreviewPath(source:string,root:string){if(!source.startsWith('/')||source.startsWith('//')||source.includes('\\')||!/^\/(?:[^?]+)\.(png|jpe?g|webp)$/i.test(source))throw Error('Invalid source');const file=resolve(root,'.'+source);if(!file.startsWith(resolve(root)+sep))throw Error('Invalid source');return file;} +let active=0;const waiting:(()=>void)[]=[]; +async function limited(work:()=>Promise){if(active>=3){if(waiting.length>=24)throw Error('Preview busy');await new Promise(done=>waiting.push(done));}else active++;try{return await work()}finally{const next=waiting.shift();if(next)next();else active--;}} +const pending=new Map>();const cache=join(tmpdir(),'prompt-previews-v1');let writes=0; +export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();} +export async function cachedPreview(identity:string,width:number,load:()=>Promise){const key=createHash('sha256').update(identity+':'+width+':webp-v1').digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const bytes=await compressPreview(await load(),width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}} +export async function loadStaticPreview(source:string){const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};} diff --git a/apps/wiki/src/pages/api/explore-feed.ts b/apps/wiki/src/pages/api/explore-feed.ts new file mode 100644 index 0000000..549f9ae --- /dev/null +++ b/apps/wiki/src/pages/api/explore-feed.ts @@ -0,0 +1,3 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {exploreFeed} from '@/lib/explore'; +export default async function handler(req:NextApiRequest,res:NextApiResponse){if(req.method!=='GET')return res.status(405).end();try{return res.json(await exploreFeed(String(req.query.q||'').slice(0,200),String(req.query.category||''),String(req.query.cursor||'')||undefined));}catch{return res.status(503).json({error:'素材库暂时无法连接'});}} diff --git a/apps/wiki/src/pages/api/preview.ts b/apps/wiki/src/pages/api/preview.ts new file mode 100644 index 0000000..77dd8a1 --- /dev/null +++ b/apps/wiki/src/pages/api/preview.ts @@ -0,0 +1,6 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {db} from '@/lib/db'; +import {getObject} from '@/lib/storage'; +import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview'; +export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}} +export const config={api:{responseLimit:false}}; diff --git a/apps/wiki/src/pages/api/wiki-index.ts b/apps/wiki/src/pages/api/wiki-index.ts index 9d186b1..65d5faa 100644 --- a/apps/wiki/src/pages/api/wiki-index.ts +++ b/apps/wiki/src/pages/api/wiki-index.ts @@ -1,3 +1,4 @@ import type {NextApiRequest,NextApiResponse} from 'next'; -import {wikiPages,wikiPath} from '@/content/wiki'; -export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# PROMPT Wiki\n\n'+wikiPages.map(p=>`- [${p.namespace}:${p.title}](${wikiPath(p)}) · [Markdown](/api/wiki/${encodeURIComponent(p.namespace+':'+p.slug)})`).join('\n')+'\n\n## 现有资料\n\n[指导文档索引](/agent/index.md)\n\n[素材分页索引](/api/feed)(使用 nextCursor 翻页)\n\n每个素材或作品 /p/{slug} 的 Markdown 位于 /api/wiki-post/{slug}。\n');} +import {staticEntries} from '@/lib/explore'; +import {exploreUrl} from '@/content/page-links'; +export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/plain; charset=utf-8');res.send('# PROMPT\n\n'+staticEntries.map(e=>`- [${e.title}](${exploreUrl(e.id)}?agent_mode=True)`).join('\n')+'\n\n素材分页索引:/api/explore-feed(使用 nextCursor 翻页)。所有内容均使用 /explore/{id}?agent_mode=True 读取纯文本。\n');} diff --git a/apps/wiki/src/pages/categories.tsx b/apps/wiki/src/pages/categories.tsx new file mode 100644 index 0000000..f19fadb --- /dev/null +++ b/apps/wiki/src/pages/categories.tsx @@ -0,0 +1,9 @@ +import type {GetServerSideProps} from 'next'; +import Link from 'next/link'; +import Head from 'next/head'; +import {Shell} from '@/components/Shell'; +import {ExploreFeed} from '@/components/ExploreFeed'; +import {staticEntries,exploreFeed,type ExploreEntry} from '@/lib/explore'; +import {tags} from '@/lib/library'; +export default function Categories({categories,category,entries,cursor,q}:{categories:string[];category:string;entries:ExploreEntry[];cursor:string|null;q:string}){return 分类 · PROMPT
全部{categories.map(c=>{c})}
} +export const getServerSideProps:GetServerSideProps=async({query})=>{const category=String(query.category||'');const q=String(query.q||'').slice(0,200);const categories=new Set(['Main','File','Template','Draft','Help',...staticEntries.flatMap(e=>e.categories)]);try{(await tags()).forEach(t=>categories.add(t.name));const data=await exploreFeed(q,category);return {props:{categories:[...categories],category,q,entries:data.entries,cursor:data.nextCursor}};}catch{return {props:{categories:[...categories],category,q,entries:staticEntries.filter(e=>(!category||e.categories.includes(category)||e.namespace===category)&&(!q||(e.title+e.summary).includes(q))),cursor:null}}}}; diff --git a/apps/wiki/src/pages/explore/[id].tsx b/apps/wiki/src/pages/explore/[id].tsx new file mode 100644 index 0000000..0112805 --- /dev/null +++ b/apps/wiki/src/pages/explore/[id].tsx @@ -0,0 +1,5 @@ +import type {GetServerSideProps} from 'next'; +import {ExploreDetail} from '@/components/ExploreDetail'; +import {getEntry,entryText,staticEntries} from '@/lib/explore'; +export default ExploreDetail; +export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{try{const entry=await getEntry(String(params?.id||''));if(!entry)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-cache');res.end(req.method==='HEAD'?undefined:entryText(entry));return {props:{}};}return {props:{entry,background:staticEntries}};}catch{res.statusCode=503;res.setHeader('Retry-After','10');res.end('内容暂时无法读取,请稍后重试');return {props:{}};}}; diff --git a/apps/wiki/src/pages/index.tsx b/apps/wiki/src/pages/index.tsx index 92f308a..f51708f 100644 --- a/apps/wiki/src/pages/index.tsx +++ b/apps/wiki/src/pages/index.tsx @@ -2,6 +2,7 @@ import type {GetServerSideProps} from 'next'; import Head from 'next/head'; import Link from 'next/link'; import {Shell} from '@/components/Shell'; -import {wikiPages,namespaces,wikiPath} from '@/content/wiki'; -export default function Home({namespace,q}:{namespace:string;q:string}){const pages=wikiPages.filter(p=>(!namespace||p.namespace===namespace)&&(!q||(p.title+p.summary+p.body).includes(q)));const categories=[...new Set(wikiPages.flatMap(p=>p.categories))];return PROMPT · 灵感百科
发现{namespaces.map(n=>{n})}
YOUR EVERYDAY KNOWLEDGE

收藏灵感,也连接知识。

每一篇笔记,都是通往下一个想法的入口。

{namespace==='File'?

你的媒体素材库

图片 → 视频 → 声音 →
:namespace==='Category'?
{categories.map(c=>
{c}

Category:{c}

浏览相关条目 →

)}
:
{pages.map((p,i)=>
{p.namespace.toUpperCase()} / 0{i+1}{p.title}知识是一种日常

{p.title}

{p.summary}

PPROMPT Wiki ♡
)}
}{!pages.length&&!['File','Category'].includes(namespace)&&

还没有匹配的条目。

}
浏览已有图文作品 →AI 内容索引 ↗
} -export const getServerSideProps:GetServerSideProps=async({query})=>({props:{namespace:String(query.namespace||''),q:String(query.q||'').slice(0,200)}}); +import {ExploreFeed} from '@/components/ExploreFeed'; +import {exploreFeed,staticEntries,type ExploreEntry} from '@/lib/explore'; +export default function Home({entries,cursor,q,unavailable=false}:{entries:ExploreEntry[];cursor:string|null;q:string;unavailable?:boolean}){return PROMPT · 发现
发现分类
{unavailable&&

素材库暂时无法连接,先看看这些公开笔记。

}
} +export const getServerSideProps:GetServerSideProps=async({query})=>{const q=String(query.q||'').slice(0,200);try{const data=await exploreFeed(q);return {props:{entries:data.entries,cursor:data.nextCursor,q}}}catch{return {props:{entries:staticEntries.filter(e=>!q||(e.title+e.summary).includes(q)),cursor:null,q,unavailable:true}}}}; diff --git a/apps/wiki/src/pages/p/[slug].tsx b/apps/wiki/src/pages/p/[slug].tsx index 019d004..70388ad 100644 --- a/apps/wiki/src/pages/p/[slug].tsx +++ b/apps/wiki/src/pages/p/[slug].tsx @@ -1,3 +1,4 @@ +import {previewUrl} from '@/content/page-links'; import type { GetServerSideProps } from "next"; import Head from "next/head"; import Link from "next/link"; @@ -17,13 +18,13 @@ export default function PostDetail({post}:{post:Post}){ return {post.title} · PROMPT
返回{section.name}{['image','video','audio'].includes(post.kind)?'File':'Main'}:{post.slug}
{Work?:selected?<>
- {selected.kind==='image'&&{selected.filename||post.title}/} + {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)=>)}
}:

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

} + {media.length>1&&
{media.map((a,i)=>)}
}:

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

}
; } -export const getServerSideProps:GetServerSideProps=async({params})=>{ - const post=await getPost(String(params?.slug||''));return post?{props:{post}}:{notFound:true}; -}; +export const getServerSideProps:GetServerSideProps=async({params,query})=>{const post=await getPost(String(params?.slug||''));return post?{redirect:{destination:'/explore/'+post.id+(String(query.agent_mode||'').toLowerCase()==='true'?'?agent_mode=True':''),permanent:true}}:{notFound:true};}; diff --git a/apps/wiki/src/pages/skills/[[...path]].tsx b/apps/wiki/src/pages/skills/[[...path]].tsx index 2bab61e..5f143f7 100644 --- a/apps/wiki/src/pages/skills/[[...path]].tsx +++ b/apps/wiki/src/pages/skills/[[...path]].tsx @@ -1,10 +1,5 @@ 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}; -}; +import {guidanceUrl} from '@/content/page-links'; +export default function Legacy(){return null;} +export const getServerSideProps:GetServerSideProps=async({params,query})=>{const parts=params?.path;if(parts===undefined)return {redirect:{destination:'/categories',permanent:false}};const path=Array.isArray(parts)?parts.join('/'):String(parts);return getGuidance(path)?{redirect:{destination:guidanceUrl(path)+(String(query.agent_mode||'').toLowerCase()==='true'?'?agent_mode=True':''),permanent:true}}:{notFound:true};}; diff --git a/apps/wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx b/apps/wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx index 21dcc14..d045708 100644 --- a/apps/wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx +++ b/apps/wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx @@ -1,11 +1,5 @@ import type {GetServerSideProps} from 'next'; -import {SkillDocs} from '@/components/SkillDocs'; -import {getSkill} from '@/content/skills/catalog'; -export default SkillDocs; -export const getServerSideProps:GetServerSideProps=async({params})=>{ - const skillId=String(params?.skill);const parts=params?.doc; - if(parts!==undefined&&(!Array.isArray(parts)||parts.length!==1||parts[0]==='index'))return {notFound:true}; - const documentId=Array.isArray(parts)?parts[0]:'index'; - const skill=getSkill(skillId); - return skill?.documents.some(d=>d.id===documentId)?{props:{skillId,documentId}}:{notFound:true}; -}; +import {getGuidance,legacyDocumentPath} from '@/content/skills/guidance'; +import {guidanceUrl} from '@/content/page-links'; +export default function Legacy(){return null;} +export const getServerSideProps:GetServerSideProps=async({params})=>{const doc=params?.doc;const path=legacyDocumentPath(String(params?.skill),Array.isArray(doc)?doc[0]:'index');return getGuidance(path)?{redirect:{destination:guidanceUrl(path),permanent:true}}:{notFound:true};}; diff --git a/apps/wiki/src/pages/tools/skills/street-people.tsx b/apps/wiki/src/pages/tools/skills/street-people.tsx index 6b76fac..c10a887 100644 --- a/apps/wiki/src/pages/tools/skills/street-people.tsx +++ b/apps/wiki/src/pages/tools/skills/street-people.tsx @@ -1 +1,4 @@ -export {SkillDocs as default} from '@/components/SkillDocs'; +export {default} from '../../skills/[[...path]]'; +import type {GetServerSideProps} from 'next'; +import {guidanceUrl} from '@/content/page-links'; +export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:guidanceUrl('street-people'),permanent:true}}); diff --git a/apps/wiki/src/pages/tools/skills/street-people/[doc].tsx b/apps/wiki/src/pages/tools/skills/street-people/[doc].tsx index e0b5175..978384e 100644 --- a/apps/wiki/src/pages/tools/skills/street-people/[doc].tsx +++ b/apps/wiki/src/pages/tools/skills/street-people/[doc].tsx @@ -1,5 +1,5 @@ +export {default} from '../[skill]/[[...doc]]'; 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}}; +import {getGuidance,legacyDocumentPath} from '@/content/skills/guidance'; +import {guidanceUrl} from '@/content/page-links'; +export const getServerSideProps:GetServerSideProps=async({params})=>{const path=legacyDocumentPath('street-people',String(params?.doc));return getGuidance(path)?{redirect:{destination:guidanceUrl(path),permanent:true}}:{notFound:true};}; diff --git a/apps/wiki/src/pages/wiki/[key].tsx b/apps/wiki/src/pages/wiki/[key].tsx index 9717609..e46ceb1 100644 --- a/apps/wiki/src/pages/wiki/[key].tsx +++ b/apps/wiki/src/pages/wiki/[key].tsx @@ -1,10 +1,6 @@ -import PostDetail from '../p/[slug]'; -import {getPost} from '@/lib/library'; -import type {Post} from '@/lib/model'; import type {GetServerSideProps} from 'next'; -import Head from 'next/head'; -import Link from 'next/link'; -import {Shell} from '@/components/Shell'; -import {findWikiPage,type WikiPage} from '@/content/wiki'; -export default function Wiki({page,post}:{page?:WikiPage;post?:Post}){if(post)return ;if(!page)return null;const key=page.namespace+':'+page.slug;return {page.title} · PROMPT Wiki
{page.namespace} : {page.slug}

{page.title}

{page.summary}

{page.namespace==='Category'?page.body.split('\n\n').map((line,i)=>{const m=line.match(/^\[(.*?)\]\((.*?)\)$/);return m?

{m[1]} →

:null}):page.body.split('\n\n').map((text,i)=>

{text}

)}
} -export const getServerSideProps:GetServerSideProps=async({params})=>{const page=findWikiPage(String(params?.key||''));if(page)return {props:{page}};const key=String(params?.key||'');const split=key.indexOf(':');const ns=key.slice(0,split);if(!['Main','File'].includes(ns))return {notFound:true};const post=await getPost(key.slice(split+1));return post&&(['image','video','audio'].includes(post.kind)?'File':'Main')===ns?{props:{post}}:{notFound:true};}; +import {getPost} from '@/lib/library'; +import {findWikiPage} from '@/content/wiki'; +import {pageId,exploreUrl} from '@/content/page-links'; +export default function Legacy(){return null;} +export const getServerSideProps:GetServerSideProps=async({params,query})=>{const key=String(params?.key||'');const page=findWikiPage(key);let destination='';if(page?.namespace==='Category')destination='/categories?category='+encodeURIComponent(page.slug);else if(page)destination=exploreUrl(pageId('wiki:'+key));else{const index=key.indexOf(':');if(!['Main','File'].includes(key.slice(0,index)))return {notFound:true};const post=await getPost(key.slice(index+1));if(post)destination=exploreUrl(post.id);}if(!destination)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true')destination+=(destination.includes('?')?'&':'?')+'agent_mode=True';return {redirect:{destination,permanent:true}};}; diff --git a/apps/wiki/src/styles/globals.css b/apps/wiki/src/styles/globals.css index e2a89c0..f63a815 100644 --- a/apps/wiki/src/styles/globals.css +++ b/apps/wiki/src/styles/globals.css @@ -11,3 +11,7 @@ /* Wiki discovery and reading surfaces */ .wiki-tabs{display:flex;gap:12px;overflow:auto;padding:10px 0 20px}.wiki-tabs a{padding:10px 20px;border-radius:30px;white-space:nowrap}.wiki-tabs .selected{background:#f2f2f2;font-weight:700}.wiki-intro{padding:28px 0 36px}.wiki-intro>span{font-size:11px;letter-spacing:3px;color:#aaa}.wiki-intro h1{font-size:34px;letter-spacing:-1px;margin:14px 0}.wiki-intro p,.wiki-card p{color:#888}.wiki-cards{columns:4 220px;column-gap:22px}.wiki-card{display:inline-block;width:100%;break-inside:avoid;margin-bottom:28px;color:inherit;text-decoration:none}.wiki-cover{border-radius:18px;padding:28px;display:flex;flex-direction:column;justify-content:space-between;min-height:260px}.wiki-cover small{font-size:11px;letter-spacing:2px}.wiki-cover strong{font-size:35px;line-height:1.4;letter-spacing:-1px}.wiki-cover>span{font-size:12px;opacity:.5}.wiki-card h2{font-size:16px;margin:14px 2px 6px}.wiki-card p{font-size:13px;line-height:1.7;margin:0 2px 10px}.wiki-card footer{display:flex;align-items:center;gap:8px;font-size:12px;color:#888}.wiki-card footer>span:last-child{margin-left:auto;font-size:20px}.wiki-avatar{background:#ff2442;color:white;border-radius:50%;width:23px;height:23px;text-align:center;line-height:23px}.wiki-bottom{display:flex;justify-content:space-between;padding:30px 0;color:#888}.wiki-article{max-width:800px;margin:40px auto;padding:40px;border:1px solid #eee;border-radius:24px}.wiki-label{color:#ff2442;font-size:13px}.wiki-article h1{font-size:38px}.wiki-summary{color:#888}.wiki-body{line-height:2;font-size:17px;margin:40px 0;white-space:pre-wrap}.wiki-article footer{border-top:1px solid #eee;padding-top:24px;font-size:13px}.wiki-empty{padding:50px;border-radius:20px;background:#faf8f5}@media(max-width:600px){.wiki-intro h1{font-size:26px}.wiki-cards{columns:2;column-gap:12px}.wiki-cover{padding:18px;min-height:220px!important}.wiki-cover strong{font-size:25px}.wiki-article{padding:22px;margin:20px 0}.wiki-article h1{font-size:28px}} + +/* Discovery and addressable note dialog */ +.discovery-tabs{display:flex;gap:12px;overflow:auto;white-space:nowrap;padding:12px 0 24px}.discovery-tabs a{padding:10px 22px;border-radius:30px;color:#777}.discovery-tabs .selected{background:#f4f4f4;color:#222;font-weight:600}.discovery-grid{columns:4 220px;column-gap:22px;padding-bottom:30px}.discovery-card{display:inline-block;width:100%;break-inside:avoid;margin-bottom:25px;text-decoration:none;color:#282828}.discovery-cover{border-radius:17px;overflow:hidden;display:flex;flex-direction:column;justify-content:space-between;padding:28px;position:relative}.discovery-cover:has(img){padding:0}.discovery-cover img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.discovery-cover strong{font-size:32px;line-height:1.45}.discovery-cover small,.discovery-cover>span{font-size:11px;letter-spacing:2px;opacity:.6}.discovery-card h2{font-size:16px;margin:11px 2px;line-height:1.5}.discovery-card footer{display:flex;gap:7px;align-items:center;color:#888;font-size:12px}.discovery-card footer>span:last-child{margin-left:auto;font-size:20px}.note-overlay{position:fixed;z-index:1000;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;padding:38px 7vw;backdrop-filter:blur(2px)}.note-dialog{width:min(1180px,100%);height:min(860px,88vh);display:grid;grid-template-columns:minmax(0,1.25fr) minmax(350px,1fr);background:white;border-radius:24px;position:relative;box-shadow:0 20px 90px #0002}.note-close{position:absolute;left:-56px;top:0;width:40px;height:40px;display:grid;place-items:center;border:0;border-radius:50%;background:#fff;color:#444;cursor:pointer}.note-stage{position:relative;min-width:0;min-height:0;background:#f8f8f8;display:flex;align-items:center;justify-content:center;border-radius:24px 0 0 24px;overflow:hidden}.note-stage>img,.note-stage>video{width:100%;height:100%;object-fit:contain}.note-stage:has(video){background:#000}.note-title-cover{width:100%;height:100%;padding:55px;display:flex;flex-direction:column;justify-content:space-between}.note-title-cover strong{font-size:clamp(32px,4vw,62px);line-height:1.5;letter-spacing:-2px}.note-title-cover small,.note-title-cover>span{font-size:13px;letter-spacing:3px;opacity:.6}.note-prev,.note-next{position:absolute;top:48%;width:35px;height:35px;background:#fff9;border:0;border-radius:50%;display:grid;place-items:center;cursor:pointer}.note-prev{left:15px}.note-next{right:15px}.note-prev:disabled,.note-next:disabled{opacity:.2}.note-count{position:absolute;right:15px;top:15px;border-radius:20px;background:#0005;color:white;padding:4px 10px;font-size:12px}.note-dots{position:absolute;bottom:24px;display:flex;gap:8px;left:0;right:0;justify-content:center}.note-dots button{width:7px;height:7px;padding:0;border:0;border-radius:50%;background:#9997;cursor:pointer}.note-dots .selected{background:#ff2442}.note-info{display:flex;flex-direction:column;min-height:0;min-width:0}.note-info>header{display:flex;align-items:center;gap:12px;padding:26px 28px;flex-shrink:0}.note-author-avatar{width:40px;height:40px;display:grid;place-items:center;background:#ff2442;color:#fff;border-radius:50%;font-weight:700}.note-info header b{font-size:15px}.note-info header small{display:block;color:#999;font-size:11px;margin-top:4px}.note-category-button{margin-left:auto;border:1px solid #ff2442;color:#ff2442;padding:8px 17px;border-radius:24px;font-size:13px}.note-content{overflow:auto;flex:1;padding:8px 28px 28px;overscroll-behavior:contain}.note-content h1{font-size:22px;line-height:1.6;margin:0 0 14px}.note-content h3{font-size:16px;margin:24px 0 10px}.note-content p{font-size:15px;line-height:1.95;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 20px}.note-content pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.7;background:#faf9f6;padding:14px;border-radius:12px}.note-content a,.note-tags{color:#315775}.note-id{font-size:11px;color:#aaa;margin-top:25px;overflow-wrap:anywhere}.note-info>footer{border-top:1px solid #eee;display:flex;gap:18px;align-items:center;padding:19px 25px;flex-wrap:wrap}.note-info footer button,.note-info footer a{font:inherit;background:none;border:0;display:flex;gap:5px;align-items:center;font-size:12px;color:#777;cursor:pointer}.note-audio{width:80%;text-align:center}.note-audio>span{font-size:100px;display:block;margin-bottom:40px}.note-audio audio{width:100%}.discovery-empty{padding:50px;color:#999;text-align:center} +@media(max-width:760px){.discovery-grid{columns:2;column-gap:12px}.discovery-cover{padding:20px}.discovery-cover strong{font-size:24px}.discovery-card h2{font-size:14px}.note-overlay{padding:0;background:#fff;align-items:flex-start;overflow:auto}.note-dialog{display:flex;flex-direction:column;width:100%;height:auto;min-height:100dvh;border-radius:0;box-shadow:none}.note-close{z-index:3;left:14px;top:14px;background:#fffc}.note-stage{height:56dvh;min-height:320px;flex-shrink:0;border-radius:0}.note-info{min-height:44dvh}.note-info>header{padding:20px}.note-content{overflow:visible;padding:0 20px 25px}.note-content h1{font-size:20px}.note-info>footer{position:sticky;bottom:0;background:white}.note-title-cover{padding:45px}.note-title-cover strong{font-size:34px}} diff --git a/apps/wiki/tests/explore.test.ts b/apps/wiki/tests/explore.test.ts new file mode 100644 index 0000000..f2c2b6a --- /dev/null +++ b/apps/wiki/tests/explore.test.ts @@ -0,0 +1,9 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {staticEntries,getEntry,entryText} from '../src/lib/explore'; +import {pageId,previewUrl} from '../src/content/page-links'; +import {guidanceDocuments} from '../src/content/skills/guidance'; +import {getServerSideProps} from '../src/pages/explore/[id]'; +test('every public document has a distinct persisted ID and canonical entry',async()=>{assert.equal(new Set(staticEntries.map(e=>e.id)).size,staticEntries.length);for(const e of staticEntries){assert.match(e.id,/^[a-f0-9]{24}$/);assert.equal((await getEntry(e.id))?.title,e.title);}for(const doc of guidanceDocuments)assert.ok(pageId('guidance:'+doc.path));assert.equal(await getEntry('unknown'),null);}); +test('agent_mode on the canonical URL emits text with media links and no HTML',async()=>{const entry=staticEntries.find(e=>e.media.length)!;let body='';const headers:Record={};const response={setHeader:(k:string,v:string)=>headers[k]=v,end:(value:string)=>{body=value}};await getServerSideProps({params:{id:entry.id},query:{agent_mode:'True'},res:response,req:{method:'GET'}} as never);assert.match(headers['Content-Type'],/text\/plain/);assert.ok(body.includes(entry.title));assert.ok(body.includes(entry.media[0].url));assert.ok(!body.includes('{const url=new URL(previewUrl('/api/media/id?variant=poster',480),'http://test');assert.equal(url.pathname,'/api/preview');assert.equal(url.searchParams.get('source'),'/api/media/id?variant=poster');assert.equal(url.searchParams.get('width'),'480');}); diff --git a/apps/wiki/tests/integration.test.ts b/apps/wiki/tests/integration.test.ts index 436c525..1f7cbdf 100644 --- a/apps/wiki/tests/integration.test.ts +++ b/apps/wiki/tests/integration.test.ts @@ -53,7 +53,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video assert.equal(articles.length,3,'Repeated publication must not duplicate posts'); assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset'); assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3); - for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('学习资料'))} + for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('内容 ID'))} assert.equal((await request('/api/feed?section=articles&path=learning-skills')).posts.length,3); 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); diff --git a/apps/wiki/tests/preview.test.ts b/apps/wiki/tests/preview.test.ts new file mode 100644 index 0000000..482cb30 --- /dev/null +++ b/apps/wiki/tests/preview.test.ts @@ -0,0 +1,7 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import sharp from 'sharp'; +import {compressPreview,previewWidth,staticPreviewPath,cachedPreview} from '../src/lib/preview'; +test('preview input validation rejects traversal and arbitrary sizes',()=>{for(const value of [1,10000,'bad'])assert.equal(previewWidth(value),null);assert.equal(previewWidth(480),480);for(const source of ['https://example.com/x.png','/../secret.png','//remote/x.png','/x.svg','/x.png?foo=bar'])assert.throws(()=>staticPreviewPath(source,'/public'));assert.equal(staticPreviewPath('/images/a.png','/public'),'/public/images/a.png');}); +test('large PNG becomes a bounded WebP with no upscaling',async()=>{const original=await sharp({create:{width:2200,height:3000,channels:3,background:'#f0e2d2'}}).png().toBuffer();const preview=await compressPreview(original,480);const info=await sharp(preview).metadata();assert.equal(info.format,'webp');assert.equal(info.width,480);assert.ok(preview.length<=100000);}); +test('identical concurrent requests share one transform and reuse the disk result',async()=>{let loads=0;const input=await sharp({create:{width:40,height:40,channels:3,background:'#fff'}}).png().toBuffer();const identity='test-'+Date.now();const load=async()=>{loads++;return input};const [a,b]=await Promise.all([cachedPreview(identity,480,load),cachedPreview(identity,480,load)]);assert.deepEqual(a.bytes,b.bytes);assert.equal(loads,1);await cachedPreview(identity,480,load);assert.equal(loads,1);}); diff --git a/apps/wiki/tests/skill-docs.test.ts b/apps/wiki/tests/skill-docs.test.ts index 026a655..19a2667 100644 --- a/apps/wiki/tests/skill-docs.test.ts +++ b/apps/wiki/tests/skill-docs.test.ts @@ -32,7 +32,7 @@ test('every guidance page has a matching Markdown URL and all document links res 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.equal(markdownDocumentUrl(doc.path),humanDocumentUrl(doc.path)+'?agent_mode=True'); assert.ok(text.includes(humanDocumentUrl(doc.path))); for(const block of doc.blocks){ if(block.text)assert.ok(text.includes(block.text)); @@ -44,8 +44,8 @@ test('every guidance page has a matching Markdown URL and all document links res 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')); + assert.ok(guidanceMarkdown('image-delivery')!.includes(markdownDocumentUrl('street-people/checks'))); + assert.ok(guidanceMarkdown('image-delivery')!.includes(markdownDocumentUrl('shadow-puppet/delivery'))); }); test('Markdown counterpart responses support GET and HEAD and reject writes or missing documents',async()=>{ diff --git a/docs/explore-stage-files.md b/docs/explore-stage-files.md new file mode 100644 index 0000000..8e12046 --- /dev/null +++ b/docs/explore-stage-files.md @@ -0,0 +1,45 @@ +# 本阶段逐文件变更说明 + +- `Dockerfile`:分离依赖下载与离线安装层,保留跨源码改动的下载缓存。 +- `apps/studio/Dockerfile`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。 +- `apps/studio/next.config.ts`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。 +- `apps/studio/src/pages/api/index.ts`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。 +- `apps/studio/src/pages/ideas/[slug].tsx`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。 +- `apps/studio/src/pages/index.tsx`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。 +- `apps/wiki/Dockerfile`:分离依赖下载与离线安装层,保留跨源码改动的下载缓存。 +- `apps/wiki/next.config.ts`:更新样式或生产烟测以支持统一详情和轻量展示。 +- `apps/wiki/package.json`:更新样式或生产烟测以支持统一详情和轻量展示。 +- `apps/wiki/scripts/smoke.mjs`:更新样式或生产烟测以支持统一详情和轻量展示。 +- `apps/wiki/src/components/ArticleGallery.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/components/Feed.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/components/Shell.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/components/SkillCard.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/components/SkillDocs.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/content/skills/guidance.ts`:维护持久内容 ID、统一地址与文档引用。 +- `apps/wiki/src/lib/library.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。 +- `apps/wiki/src/pages/api/wiki-index.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/index.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/p/[slug].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/skills/[[...path]].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/tools/skills/street-people.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/tools/skills/street-people/[doc].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/wiki/[key].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/styles/globals.css`:更新样式或生产烟测以支持统一详情和轻量展示。 +- `apps/wiki/tests/integration.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。 +- `apps/wiki/tests/skill-docs.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。 +- `docs/monorepo.md`:记录新入口、兼容关系、图片预览和构建缓存行为。 +- `pnpm-workspace.yaml`:关闭运行命令前的自动依赖重装,使用显式安装与冻结锁文件。 +- `scripts/deploy-studio.sh`:连接 Wiki Docker 网络、验证新路径并按 Studio 相关改动判断过期发布。 +- `apps/wiki/src/components/ExploreDetail.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/components/ExploreFeed.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。 +- `apps/wiki/src/content/page-ids.json`:维护持久内容 ID、统一地址与文档引用。 +- `apps/wiki/src/content/page-links.ts`:维护持久内容 ID、统一地址与文档引用。 +- `apps/wiki/src/lib/explore.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。 +- `apps/wiki/src/lib/preview.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。 +- `apps/wiki/src/pages/api/explore-feed.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/api/preview.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/categories.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/src/pages/explore/`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。 +- `apps/wiki/tests/explore.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。 +- `apps/wiki/tests/preview.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。 diff --git a/docs/monorepo.md b/docs/monorepo.md index 38b1fe2..83bf8cb 100644 --- a/docs/monorepo.md +++ b/docs/monorepo.md @@ -15,3 +15,13 @@ Three path-filtered Gitea workflows run on ces-control. Changes to one app trigg Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; firewall access to 18134 may need operator configuration. Python publishes `prompt-python:` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/wiki/Dockerfile` mirrors it for discovery. CI uses root build context and application-scoped COPY instructions. Wiki runs its existing Docker integration suite before deploying. The former Web-only release path is retired because its source-copy and image assumptions predated the monorepo. No `down -v` is used in production. + +## Explore 页面与轻量图片 + +首页 `/` 与分类 `/categories` 统一浏览;内容固定地址 `/explore/`。文档 ID 持久保存在 `apps/wiki/src/content/page-ids.json`,改标题或位置时保留 ID;数据库素材使用原有 UUID。旧 `/skills/...`、`/p/...` 和 `/wiki/...` 网页跳转到内容地址,导出画布与 API 保持兼容。`?agent_mode=True`(大小写不敏感)在相同地址返回 `text/plain`,保留全部正文与媒体链接。 + +`/api/preview` 只接受本站静态栅格图或媒体 ID,限制四档尺寸,输出 WebP:列表目标最多 100 KB、详情目标最多 220 KB。原图按需下载。服务端预览使用并发限制、请求合并、磁盘缓存和 ETag;缓存数量有限并定期清理。SVG 图标不经过栅格化。 + +Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口是 `18134/studio`;只做辅助入口,公网可达性取决于服务器网络规则。 + +Docker 在单独的依赖下载层执行 `pnpm fetch`,再离线安装。源码变化复用依赖层,包脚本变化复用已下载的包。CI 不清理 Docker 缓存,不使用 `--no-cache`。 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8132a67..e88dc57 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,5 @@ sharedWorkspaceLockfile: false allowBuilds: sharp: true esbuild: true + +verifyDepsBeforeRun: false diff --git a/scripts/deploy-studio.sh b/scripts/deploy-studio.sh index 667855c..35e2c88 100644 --- a/scripts/deploy-studio.sh +++ b/scripts/deploy-studio.sh @@ -6,16 +6,19 @@ sha=${1:?release SHA required} exec 9>/var/lock/prompt-studio.lock flock -w 300 9 latest=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) -[ "$latest" = "$sha" ] || exit 0 +if [ "$latest" != "$sha" ]; then + git -c safe.directory="$PWD" fetch origin main + git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/studio.yml pnpm-workspace.yaml || exit 0 +fi mkdir -p /opt/prompt-studio previous=$(cat /opt/prompt-studio/image 2>/dev/null || true) -write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["18134:3000"]\n' "$1" > /opt/prompt-studio/compose.yml; } +write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["18134:3000"]\n networks:\n wiki:\n aliases: [prompt-studio]\nnetworks:\n wiki:\n external: true\n name: prompt_default\n' "$1" > /opt/prompt-studio/compose.yml; } write_compose "prompt-studio:$sha" rollback(){ if [ -n "$previous" ]; then write_compose "$previous"; docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d; fi; } trap rollback ERR docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d for attempt in {1..40}; do - if curl -fsS http://127.0.0.1:18134/api/index >/dev/null; then printf '%s' "prompt-studio:$sha" > /opt/prompt-studio/image; exit 0; fi + if curl -fsS http://127.0.0.1:18134/studio/api/index >/dev/null; then printf '%s' "prompt-studio:$sha" > /opt/prompt-studio/image; exit 0; fi sleep 2 done false