- {source?
:post.kind==='audio'?
{Array.from({length:36},(_,i)=>)}
:
{post.kind==='video'?:}{label}
}
+ return
+ {source?
:post.kind==='audio'?
{Array.from({length:36},(_,i)=>)}
:
{post.kind==='video'?:}{label}
}
{count>1&&
{count} }{video&&
{video.has_audio?'有声音':'无声音'} }
{post.title} {label}{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}
;
}
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
setOpen(!open)}>{open?:}
setOpen(false)}>PROMPT
{groups.map((g,i)=>{g.label&&
{g.label}
}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return
setOpen(false)}>
{item.label} ;})} )}
-
-
{children}{open&&
setOpen(false)}/>}
+
+
{children} {open&&setOpen(false)}/>}
;
}
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.title} }
+export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return
{skill.title} }
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&&
文档与来源 本文的文档、参考图与接口
setOpen(false)}>
{(['document','image','api'] as const).map(kind=>{kind==='document'?'指导文档':kind==='image'?'参考与示意图':'API 接口'} {doc.resources.filter(r=>r.kind===kind).map(r=>setSelected(r)}>{r.method||(r.kind==='image'?'IMG':'MD')} {r.title}{r.human||r.href}
)} )}
- {selected&&{selected.title} {selected.kind==='image'?
:selected.kind==='document'?
{guidanceMarkdown(selected.documentPath!)} :<>
{selected.method} {selected.href}{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)} {selected.description}
>}
+ {selected&&
{selected.title} {selected.kind==='image'?
:selected.kind==='document'?
{guidanceMarkdown(selected.documentPath!)} :<>
{selected.method} {selected.href}{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)} {selected.description}
>}
}
}
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}
)}
}{!pages.length&&!['File','Category'].includes(namespace)&&还没有匹配的条目。
} }
-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.kind==='image'&&
}
{selected.kind==='video'&&
}
{selected.kind==='audio'&&}
{media.length>1&&<>setIndex(i=>i-1)}> setIndex(i=>i+1)}> {index+1} / {media.length} >}
{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)=>
setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'? :a.variants.includes('poster')? :{a.kind==='audio'?'声音':'视频'} {i+1} } )}
}>:}
+ {media.length>1&&{media.map((a,i)=>
setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'? :a.variants.includes('poster')? :{a.kind==='audio'?'声音':'视频'} {i+1} } )}
}>:}
A PIECE OF YOUR COLLECTION {post.title} {new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}· {Work?8:media.length} {Work?'张图':'份素材'}
{post.assets.filter(a=>a.kind==='text').map(a=>
{a.body}
)}
{post.tags.map(t=> # {t.name})}
@@ -31,6 +32,4 @@ export default function PostDetail({post}:{post:Post}){
;
}
-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