Compare commits

..

1 Commits

Author SHA1 Message Date
codex-bot 68155fc0f7 Fix guidance directory counts and preserve submitted filters
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:46:22 +08:00
5 changed files with 90 additions and 8 deletions
+1 -4
View File
@@ -49,11 +49,8 @@ async function main() {
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
assert.equal((await request('/')).status,200,'Public knowledge homepage');
assert.equal((await request('/account')).status,200,'Public account page');
const homeData = await request(`/_next/data/${buildId}/index.json`);
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/library/import.json`]) {
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) {
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
}
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
+70 -2
View File
@@ -1,7 +1,75 @@
import Head from 'next/head';
import Link from 'next/link';
import {useEffect,useState} from 'react';
import {useRouter} from 'next/router';
import {useEffect,useState,type FormEvent} from 'react';
import GuidanceShell from '@/components/GuidanceShell';
import {guides} from '@/content/guides/catalog';
import s from '@/styles/guidance.module.css';
export default function Home(){const [published,setPublished]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[loadError,setLoadError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setPublished(b.documents);}).catch(e=>setLoadError(e.message));},[]);const[q,setQ]=useState(''),[kind,setKind]=useState('all');const items=guides.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase()));return <GuidanceShell><Head><title>织页 · 为 Agent 找到下一页</title></Head><section className={s.hero}><span className={s.eyebrow}>A WEB OF USEFUL KNOWLEDGE</span><h1>一个起点,<br/>连接下一种可能。</h1><p>每一篇文档,都能独立帮助你的 Agent。<br/>从当前问题出发,沿着有用的链接继续探索。</p><label className={s.search}>⌕ <input aria-label="搜索文档" placeholder="搜索文档、任务或一种想法…" value={q} onChange={e=>setQ(e.target.value)}/><kbd>探索</kbd></label><div className={s.topics}>{['代码审查','规划','验证'].map(t=><button key={t} onClick={()=>setQ(t)}>{t} ↗</button>)}</div></section><main className={s.layout}><aside className={s.sidebar}><h2>文档目录</h2>{[['all','全部文档'],['guide','操作指导'],['context','背景知识'],['evidence','事实依据']].map(([key,label])=><button key={key} aria-pressed={kind===key} onClick={()=>setKind(key)}>{label}<span>{guides.filter(g=>key==='all'||g.kind===key).length}</span></button>)}<p>一个文档就是一个入口。<br/>集合是组织方式,<br/>无需整套安装。</p></aside><section><div className={s.sectionTitle}><h2>用户发布</h2><Link href="/write">发布一篇文档 ↗</Link></div>{loadError&&<p>{loadError}</p>}<div className={s.list}>{published.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary}`.toLowerCase().includes(q.toLowerCase())).map(g=><article className={s.row} key={g.id}><div className={s.icon}>↗</div><div><div className={s.meta}>{g.id} · {g.kind} · v{g.revision}</div><h3><Link href={`/documents/${g.id}?revision=${g.revision}`}>{g.title}</Link></h3><p>{g.summary}</p></div></article>)}</div><div className={s.sectionTitle}><h2>从这里开始 <small>{items.length} 篇</small></h2><span>协议样例 · v0.1</span></div><div className={s.list}>{items.map(g=><article className={s.row} key={g.id}><div className={s.icon}>{g.kind==='guide'?'↗':'≋'}</div><div><div className={s.meta}>织页 / protocol-examples · r{g.revision}</div><h3><Link href={`/guide/${g.id}`}>{g.title}</Link></h3><p>{g.summary}</p><div className={s.tags}>{g.tags.map(t=><span key={t}>{t}</span>)}<span>↗ {g.links.length} 个关联</span></div></div></article>)}{!items.length&&<p>没有匹配文档,请尝试其他关键词或分类。</p>}</div></section><aside className={s.right}><div className={s.manifest}><span className={s.eyebrow}>DOCUMENTS, CONNECTED.</span><h2>知识不必<br/>打包带走。</h2><p>读一页,解决眼前的问题。<br/>需要更多时,再打开下一页。</p><Link href="/protocol">了解文档协议 ↗</Link></div><div className={s.note}><h3>为两种 Agent 而写</h3><p>Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。</p><h3>这是一个早期原型</h3><p>已验证邮箱用户可以发布独立文档,更新会创建不可变版本。下方协议样例与真实用户文档分别展示。</p></div></aside></main></GuidanceShell>}
type PublishedDocument={id:string;revision:number;title:string;summary:string;kind:string};
const categories=[['all','全部文档'],['guide','操作指导'],['context','背景知识'],['evidence','事实依据']];
export default function Home(){
const router=useRouter();
const [published,setPublished]=useState<PublishedDocument[]>([]);
const [loadError,setLoadError]=useState('');
const [loading,setLoading]=useState(true);
const [q,setQ]=useState('');
const [kind,setKind]=useState('all');
useEffect(()=>{
let active=true;
fetch('/api/documents').then(async r=>{
const b=await r.json();if(!r.ok)throw Error(b.error);
if(active)setPublished(b.documents);
}).catch(e=>{if(active)setLoadError(e.message);}).finally(()=>{if(active)setLoading(false);});
return()=>{active=false;};
},[]);
useEffect(()=>{
if(!router.isReady)return;
setQ(typeof router.query.q==='string'?router.query.q:'');
setKind(typeof router.query.kind==='string'&&categories.some(([key])=>key===router.query.kind)?router.query.kind:'all');
},[router.isReady,router.query.q,router.query.kind]);
function rememberFilters(query:string,category:string){
const next:{q?:string;kind?:string}={};
if(query)next.q=query;if(category!=='all')next.kind=category;
void router.push({pathname:'/',query:next},undefined,{shallow:true,scroll:false});
}
function search(e:FormEvent){e.preventDefault();rememberFilters(q,kind);}
function chooseKind(value:string){setKind(value);rememberFilters(q,value);}
function clearFilters(){setQ('');setKind('all');rememberFilters('','all');}
const matches=(g:{title:string;summary:string;kind:string;tags?:string[]})=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags?.join(' ')||''}`.toLowerCase().includes(q.trim().toLowerCase());
const items=guides.filter(matches),userItems=published.filter(matches);
const total=items.length+userItems.length;
return <GuidanceShell>
<Head><title>织页 · 为 Agent 找到下一页</title></Head>
<section className={s.hero}>
<span className={s.eyebrow}>A WEB OF USEFUL KNOWLEDGE</span>
<h1>一个起点,<br/>连接下一种可能。</h1>
<p>每一篇文档,都能独立帮助你的 Agent。<br/>从当前问题出发,沿着有用的链接继续探索。</p>
<form className={s.search} role="search" onSubmit={search}>
<span aria-hidden="true">⌕</span><input aria-label="搜索文档" placeholder="搜索文档、任务或一种想法…" value={q} onChange={e=>setQ(e.target.value)}/><button type="submit">搜索</button>
</form>
<div className={s.topics}>{['代码审查','规划','验证'].map(t=><button key={t} onClick={()=>{setQ(t);rememberFilters(t,kind);}}>{t} ↗</button>)}</div>
</section>
<main className={s.layout}>
<aside className={s.sidebar}>
<h2>文档目录</h2>
{categories.map(([key,label])=><button key={key} aria-pressed={kind===key} onClick={()=>chooseKind(key)}>{label}<span>{[...guides,...published].filter(g=>key==='all'||g.kind===key).length}</span></button>)}
<p>一个文档就是一个入口。<br/>集合是组织方式,<br/>无需整套安装。</p>
</aside>
<section aria-label="文档搜索结果">
<div className={s.sectionTitle}><p role="status">{loading?'正在加载用户文档…':loadError?`协议样例 ${items.length} 篇;用户文档加载失败`:`找到 ${total} 篇文档`}</p>{(q||kind!=='all')&&<button type="button" onClick={clearFilters}>清除所有筛选</button>}</div>
{loadError&&<p role="alert">用户文档暂未加载:{loadError}。下方仍可阅读协议样例。</p>}
{!loading&&!loadError&&total===0&&<p>没有匹配文档,请尝试其他关键词或清除筛选。</p>}
<div className={s.sectionTitle}><h2>用户发布 <small>{userItems.length} 篇</small></h2><Link href="/write">发布一篇文档 ↗</Link></div>
<div className={s.list}>{userItems.map(g=><article className={s.row} key={g.id}><div className={s.icon}>↗</div><div><div className={s.meta}>{g.id} · {g.kind} · v{g.revision}</div><h3><Link href={`/documents/${g.id}?revision=${g.revision}`}>{g.title}</Link></h3><p>{g.summary}</p></div></article>)}</div>
{!loading&&!loadError&&!published.length&&<p>还没有用户文档。你可以发布第一篇,或先阅读下方样例。</p>}
<div className={s.sectionTitle}><h2>从这里开始 <small>{items.length} 篇</small></h2><span>协议样例 · v0.1</span></div>
<div className={s.list}>{items.map(g=><article className={s.row} key={g.id}><div className={s.icon}>{g.kind==='guide'?'↗':'≋'}</div><div><div className={s.meta}>织页 / protocol-examples · r{g.revision}</div><h3><Link href={`/guide/${g.id}`}>{g.title}</Link></h3><p>{g.summary}</p><div className={s.tags}>{g.tags.map(t=><span key={t}>{t}</span>)}<span>↗ {g.links.length} 个关联</span></div></div></article>)}</div>
</section>
<aside className={s.right}>
<div className={s.manifest}><span className={s.eyebrow}>DOCUMENTS, CONNECTED.</span><h2>知识不必<br/>打包带走。</h2><p>读一页,解决眼前的问题。<br/>需要更多时,再打开下一页。</p><Link href="/protocol">了解文档协议 ↗</Link></div>
<div className={s.note}><h3>为两种 Agent 而写</h3><p>Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。</p><h3>这是一个早期原型</h3><p>已验证邮箱用户可以发布独立文档,更新会创建不可变版本。下方协议样例与真实用户文档分别展示。</p></div>
</aside>
</main>
</GuidanceShell>;
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,9 +34,9 @@ test('proxy protects APIs, data routes, static content and Studio; fails closed'
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
try{
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/library/import','/skills/example.png','/_next/data/build/library/import.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
for(const path of ['/','/_next/data/build/index.json','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
for(const path of ['/','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
@@ -36,6 +36,22 @@ export async function verifyGuidancePublishing(base:string){
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
await page.goto(base+'/',{waitUntil:'domcontentloaded'});
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).textContent(),httpDoc.title);
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
const search=page.getByRole('textbox',{name:'搜索文档'});
await search.fill(httpDoc.title);await search.press('Enter');
await page.waitForURL(url=>url.searchParams.get('q')===httpDoc.title);
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
assert.equal(await page.getByText('没有匹配文档',{exact:false}).count(),0);
await page.reload({waitUntil:'domcontentloaded'});
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
assert.equal(await search.inputValue(),httpDoc.title);
await page.getByRole('button',{name:'事实依据'}).click();
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence');
await page.getByText('没有匹配文档,请尝试其他关键词或清除筛选。',{exact:true}).waitFor();
await page.getByRole('button',{name:'清除所有筛选'}).click();
await page.waitForURL(url=>url.search==='');
await page.locator(link).waitFor({state:'visible'});
assert.equal(await search.inputValue(),'');
await page.locator('header a[href="/documents"]').click();await page.locator(link).waitFor({state:'visible'});
await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');