Unify content under ID-based note dialogs with lightweight previews and Studio proxy
This commit is contained in:
+4
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));}
|
||||
|
||||
@@ -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 <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/api/export/'+idea.slug+'?format=markdown'}/></Head><header><Link href="/">← 我的创作</Link><a href={'/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export default function IdeaPage({idea}:{idea:Idea}){return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><Link href="/">← 我的创作</Link><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
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}};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import Link from 'next/link';
|
||||
import {ideas} from '../content/ideas';
|
||||
export default function Home(){return <main className="workspace"><aside><b className="logo">PROMPT / 创作</b><p>想法 · 页面 · 作品</p><a href="/">我的创作</a><a href="/api/index">AI 内容索引 ↗</a></aside><section><small>YOUR CREATIVE WORKSPACE</small><h1>今天,想表达什么?</h1><p>一个页面,装下一个想法。让内容慢慢长成作品。</p><div className="grid">{ideas.map(i=><Link className="card" href={'/ideas/'+i.slug} key={i.slug}><div className="cover">{i.title}</div><h2>{i.title}</h2><p>{i.summary}</p><span>打开创作 →</span></Link>)}</div><p className="hint">新增作品:在 src/content/ideas.ts 添加内容,页面与导出会同步生成。</p></section></main>}
|
||||
export default function Home(){return <main className="workspace"><aside><b className="logo">PROMPT / 创作</b><p>想法 · 页面 · 作品</p><a href="/">我的创作</a><a href="/studio/api/index">AI 内容索引 ↗</a></aside><section><small>YOUR CREATIVE WORKSPACE</small><h1>今天,想表达什么?</h1><p>一个页面,装下一个想法。让内容慢慢长成作品。</p><div className="grid">{ideas.map(i=><Link className="card" href={'/ideas/'+i.slug} key={i.slug}><div className="cover">{i.title}</div><h2>{i.title}</h2><p>{i.summary}</p><span>打开创作 →</span></Link>)}</div><p className="hint">新增作品:在 src/content/ideas.ts 添加内容,页面与导出会同步生成。</p></section></main>}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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+"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('<html'));
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/skills/shadow-puppet/egret-example.png')}&width=480`);
|
||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||
console.log("Production smoke test passed");
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {previewUrl} from '@/content/page-links';
|
||||
import Link from "next/link";
|
||||
import {useEffect,useState} from "react";
|
||||
import {ChevronLeft,ChevronRight,RefreshCw,ExternalLink} from "lucide-react";
|
||||
@@ -9,5 +10,5 @@ export function ArticleGallery({slug}:{slug:string}){
|
||||
useEffect(()=>{let live=true;const load=async()=>{try{const r=await fetch(`/api/posts/${slug}/snapshots`);if(!r.ok)throw Error();const s=await r.json();if(live){setState(s);setError('');}}catch{if(live)setError('截图状态读取失败,请重试。');}};void load();const timer=setInterval(()=>void load(),3000);return()=>{live=false;clearInterval(timer)};},[slug]);
|
||||
async function refresh(all=false){setRequesting(true);setError('');try{const r=await fetch(`/api/posts/${slug}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(all?{}:{page:index})});if(!r.ok)throw Error();setState(s=>({...s,job:{status:'pending',error:null}}));}catch{setError('刷新没有完成,请重试。')}finally{setRequesting(false)}}
|
||||
const selected=state.pages.find(p=>p.page_index===index);const url=`/p/${slug}/pages/${index}`;
|
||||
return <div className="snapshot-reader"><header><div><b>这篇笔记 · 8 张图</b><p>{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}</p></div><button className="secondary-button" disabled={pending||requesting} onClick={()=>void refresh()}><RefreshCw size={15}/>刷新这张截图</button><button className="text-link" disabled={pending||requesting} onClick={()=>void refresh(true)}>更新全部</button></header>{error&&<p role="alert">{error}</p>}{state.job?.status==='failed'&&<p role="alert">截图未完成,保留上次版本。可以重新刷新。</p>}<div className="snapshot-stage">{selected?<a href={url} target="_blank" rel="noreferrer"><img src={mediaUrl(selected.asset_id)} alt={`笔记第 ${index} 张截图`} width={1080} height={1440}/></a>:<div className="snapshot-placeholder">这张图还没有截图。<Link href={url} target="_blank">先打开网页原稿 ↗</Link></div>}</div><nav><button aria-label="上一张" disabled={index===1} onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><span>{index} / 8</span><button aria-label="下一张" disabled={index===8} onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><Link href={url} target="_blank"><ExternalLink size={15}/>打开这张图的网页</Link></nav><div className="snapshot-thumbs">{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return <button aria-label={`查看第 ${i+1} 张截图`} aria-pressed={index===i+1} onClick={()=>setIndex(i+1)} key={i}>{p?<img src={mediaUrl(p.asset_id)} alt=""/>:<span>{i+1}</span>}</button>})}</div></div>;
|
||||
return <div className="snapshot-reader"><header><div><b>这篇笔记 · 8 张图</b><p>{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}</p></div><button className="secondary-button" disabled={pending||requesting} onClick={()=>void refresh()}><RefreshCw size={15}/>刷新这张截图</button><button className="text-link" disabled={pending||requesting} onClick={()=>void refresh(true)}>更新全部</button></header>{error&&<p role="alert">{error}</p>}{state.job?.status==='failed'&&<p role="alert">截图未完成,保留上次版本。可以重新刷新。</p>}<div className="snapshot-stage">{selected?<a href={url} target="_blank" rel="noreferrer"><img src={previewUrl(mediaUrl(selected.asset_id),1280)} alt={`笔记第 ${index} 张截图`} width={1080} height={1440}/></a>:<div className="snapshot-placeholder">这张图还没有截图。<Link href={url} target="_blank">先打开网页原稿 ↗</Link></div>}</div><nav><button aria-label="上一张" disabled={index===1} onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><span>{index} / 8</span><button aria-label="下一张" disabled={index===8} onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><Link href={url} target="_blank"><ExternalLink size={15}/>打开这张图的网页</Link></nav><div className="snapshot-thumbs">{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return <button aria-label={`查看第 ${i+1} 张截图`} aria-pressed={index===i+1} onClick={()=>setIndex(i+1)} key={i}>{p?<img src={previewUrl(mediaUrl(p.asset_id),240)} alt=""/>:<span>{i+1}</span>}</button>})}</div></div>;
|
||||
}
|
||||
|
||||
@@ -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 <pre key={i}>{part.replace(/^```[^\n]*\n?|```$/g,'')}</pre>;if(part.startsWith('## '))return <h3 key={i}>{part.slice(3)}</h3>;return <p key={i}>{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?<a key={j} href={m[2].replace(/\?agent_mode=True$/,'')}>{m[1]}</a>:piece})}</p>})}</>}
|
||||
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<HTMLButtonElement>(null);const detailRef=useRef<HTMLDivElement>(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<HTMLElement>('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 <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head><div aria-hidden="true" inert><ExploreFeed initial={background}/></div><div className="note-overlay" onClick={close}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}><button className="note-close" ref={closeRef} aria-label="关闭详情" onClick={close}><X/></button><section className={'note-stage '+(!media?'note-text-stage':'')}>
|
||||
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>PROMPT / 灵感笔记</span></div>}
|
||||
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
|
||||
</section><section className="note-info"><header><span className="note-author-avatar">P</span><div><b>PROMPT</b><small>{entry.namespace} · 灵感百科</small></div><Link href="/categories" className="note-category-button">查看分类</Link></header><div className="note-content"><h1 id="note-title">{entry.title}</h1><TextBody text={entry.markdown}/><div className="note-tags">{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}</div><div className="note-id">内容 ID · {entry.id}</div></div><footer><button onClick={async()=>{try{const url=window.location.origin+exploreUrl(entry.id);if(navigator.clipboard)await navigator.clipboard.writeText(url);else{const input=document.createElement('textarea');input.value=url;document.body.appendChild(input);input.select();document.execCommand('copy');input.remove();}setCopied(true)}catch{setCopied(false)}}}><LinkIcon size={18}/>{copied?'已复制':'复制链接'}</button><a href={exploreUrl(entry.id)+'?agent_mode=True'}><FileText size={18}/>纯文本</a>{media&&<a href={media.url} target="_blank" rel="noreferrer">原文件 ↗</a>}</footer></section></div></div></Shell>}
|
||||
@@ -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 <><div className="discovery-grid">{entries.map((entry,i)=><Link href={exploreUrl(entry.id)} key={entry.id} className="discovery-card"><div className="discovery-cover" style={{background:entry.color,aspectRatio:i%3===1?'1 / 1':'3 / 4'}}>{entry.media[0]?<img src={previewUrl(entry.media[0].poster||entry.media[0].url,480)} alt={entry.media[0].title} loading={i<4?'eager':'lazy'} decoding="async"/>:<><small>{entry.namespace.toUpperCase()}</small><strong>{entry.title}</strong><span>记录 · 连接 · 灵感</span></>}</div><h2>{entry.title}</h2><footer><span className="wiki-avatar">P</span><span>{entry.categories[0]||entry.namespace}</span><span>♡</span></footer></Link>)}</div>{!entries.length&&<p className="discovery-empty">这个分类还没有内容。</p>}{cursor&&<div className="load-more"><button disabled={busy} onClick={more}>{busy?'加载中…':error||'再看一些'}</button></div>}</>}
|
||||
@@ -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 <Link className="post-card" href={`/p/${post.slug}`}><div className={`post-cover cover-${post.kind}`} style={image?.width&&image?.height?{aspectRatio:`${image.width}/${image.height}`,maxHeight:460}:undefined}>
|
||||
{source?<img src={source} alt={post.title} loading="lazy"/>:post.kind==='audio'?<div className="waveform">{Array.from({length:36},(_,i)=><i key={i} style={{height:12+((i*31)%65)}}/>)}</div>:<div className="cover-symbol">{post.kind==='video'?<Film size={42}/>:<Layers size={42}/>}<span>{label}</span></div>}
|
||||
return <Link className="post-card" href={`/explore/${post.id}`}><div className={`post-cover cover-${post.kind}`} style={image?.width&&image?.height?{aspectRatio:`${image.width}/${image.height}`,maxHeight:460}:undefined}>
|
||||
{source?<img src={previewUrl(source,480)} alt={post.title} loading="lazy"/>:post.kind==='audio'?<div className="waveform">{Array.from({length:36},(_,i)=><i key={i} style={{height:12+((i*31)%65)}}/>)}</div>:<div className="cover-symbol">{post.kind==='video'?<Film size={42}/>:<Layers size={42}/>}<span>{label}</span></div>}
|
||||
{count>1&&<span className="cover-count"><Layers size={12}/>{count}</span>}{video&&<span className="cover-badge">{video.has_audio?'有声音':'无声音'}</span>}
|
||||
</div><h3 title={post.title}>{post.title}</h3><div className="post-meta"><span><i className={`type-dot ${post.kind}`}/>{label}</span><time>{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}</time></div></Link>;
|
||||
}
|
||||
|
||||
@@ -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 <div className="app-shell"><button className="mobile-menu icon-button" aria-label={open?"关闭导航":"展开导航"} aria-expanded={open} onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="PROMPT 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>PROMPT</strong></Link>
|
||||
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
|
||||
<div className="sidebar-bottom"><Link href="/library/import" className="import-link"><Upload size={22}/><span>导入素材</span><ArrowUpRight size={17}/></Link></div>
|
||||
</aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{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}:{}});}}><Search size={24}/><input aria-label="搜索素材包" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>setQuery('')}><X size={20}/></button>}</form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
|
||||
</aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:query?{q:query}:{}});}}><Search size={24}/><input aria-label="搜索素材包" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>setQuery('')}><X size={20}/></button>}</form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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 <Link href={skill.href} className="post-card skill-pin"><div className="post-cover skill-pin-cover"><img src={skill.image} alt={skill.alt} loading="lazy"/></div><h3 title={skill.title}>{skill.title}</h3><div className="post-meta"><span>{skill.category}</span><ArrowUpRight size={14} aria-hidden="true"/></div></Link>}
|
||||
export function SkillCard({skill}:{skill:typeof skillCatalog[number]}){return <Link href={guidanceUrl(skill.id)} className="post-card skill-pin"><div className="post-cover skill-pin-cover"><img src={previewUrl(skill.image,480)} alt={skill.alt} loading="lazy"/></div><h3 title={skill.title}>{skill.title}</h3><div className="post-meta"><span>{skill.category}</span><ArrowUpRight size={14} aria-hidden="true"/></div></Link>}
|
||||
|
||||
@@ -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
|
||||
</article>
|
||||
{open&&<aside className="docs-sources" ref={panel} tabIndex={-1} aria-label="文档与资源"><header><div><b>文档与来源</b><small>本文的文档、参考图与接口</small></div><button aria-label="关闭文档与来源" onClick={()=>setOpen(false)}><X size={18}/></button></header>
|
||||
{(['document','image','api'] as const).map(kind=><section key={kind}><h3>{kind==='document'?'指导文档':kind==='image'?'参考与示意图':'API 接口'}</h3>{doc.resources.filter(r=>r.kind===kind).map(r=><button key={r.id} className={selected?.id===r.id?'selected':''} onClick={()=>setSelected(r)}><span>{r.method||(r.kind==='image'?'IMG':'MD')}</span><div>{r.title}<small>{r.human||r.href}</small></div></button>)}</section>)}
|
||||
{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={selected.href} alt={selected.title}/>:selected.kind==='document'?<pre>{guidanceMarkdown(selected.documentPath!)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)}</pre><p>{selected.description}</p></>}
|
||||
{selected&&<div className="docs-resource-preview"><h3>{selected.title}</h3>{selected.kind==='image'?<img src={previewUrl(selected.href,1280)} alt={selected.title}/>:selected.kind==='document'?<pre>{guidanceMarkdown(selected.documentPath!)}</pre>:<><code>{selected.method} {selected.href}</code><pre>{JSON.stringify(selected.detail||{method:selected.method,path:selected.href},null,2)}</pre><p>{selected.description}</p></>}
|
||||
<div className="docs-resource-actions">{selected.human&&<Link href={selected.human}>打开文档网页 ↗</Link>}{selected.method!=='POST'&&!selected.template&&<a href={selected.href} target="_blank" rel="noreferrer">{selected.kind==='document'?'打开 Markdown':'打开资源'} ↗</a>}</div>
|
||||
</div>}
|
||||
</aside>}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<string,string>)[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)});}
|
||||
@@ -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','图像交付与来源记录');
|
||||
|
||||
@@ -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<ExploreEntry|null>{
|
||||
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';}
|
||||
@@ -13,6 +13,10 @@ export async function getPost(slug: string): Promise<Post | null> {
|
||||
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<Post|null>{
|
||||
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<FeedData> {
|
||||
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");
|
||||
|
||||
@@ -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<T>(work:()=>Promise<T>){if(active>=3){if(waiting.length>=24)throw Error('Preview busy');await new Promise<void>(done=>waiting.push(done));}else active++;try{return await work()}finally{const next=waiting.shift();if(next)next();else active--;}}
|
||||
const pending=new Map<string,Promise<Buffer>>();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<Buffer>){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)};}
|
||||
@@ -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:'素材库暂时无法连接'});}}
|
||||
@@ -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<Buffer>};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<Uint8Array>){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}};
|
||||
@@ -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');}
|
||||
|
||||
@@ -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 <Shell><Head><title>分类 · PROMPT</title></Head><div className="discovery-tabs"><Link href="/categories" className={!category?'selected':''}>全部</Link>{categories.map(c=><Link className={c===category?'selected':''} href={'/categories?category='+encodeURIComponent(c)} key={c}>{c}</Link>)}</div><ExploreFeed key={category+q} initial={entries} cursor={cursor} q={q} category={category}/></Shell>}
|
||||
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}}}};
|
||||
@@ -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:{}};}};
|
||||
@@ -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 <Shell><Head><title>PROMPT · 灵感百科</title><link rel="alternate" type="text/markdown" href="/api/wiki-index"/></Head><div className="wiki-tabs"><Link href="/" className={!namespace?'selected':''}>发现</Link>{namespaces.map(n=><Link href={'/?namespace='+n} className={namespace===n?'selected':''} key={n}>{n}</Link>)}</div><div className="wiki-intro"><span>YOUR EVERYDAY KNOWLEDGE</span><h1>收藏灵感,也连接知识。</h1><p>每一篇笔记,都是通往下一个想法的入口。</p></div>{namespace==='File'?<div className="wiki-empty"><h2>你的媒体素材库</h2><Link href="/images">图片 → </Link><Link href="/videos">视频 → </Link><Link href="/audio">声音 →</Link></div>:namespace==='Category'?<div className="wiki-cards">{categories.map(c=><Link className="wiki-card" key={c} href={'/wiki/'+encodeURIComponent('Category:'+c)}><div className="wiki-cover" style={{background:'#e6e9ef'}}>{c}</div><h2>Category:{c}</h2><p>浏览相关条目 →</p></Link>)}</div>:<div className="wiki-cards">{pages.map((p,i)=><Link className="wiki-card" href={wikiPath(p)} key={p.slug}><div className="wiki-cover" style={{background:p.color,minHeight:i%2?280:340}}><small>{p.namespace.toUpperCase()} / 0{i+1}</small><strong>{p.title}</strong><span>知识是一种日常</span></div><h2>{p.title}</h2><p>{p.summary}</p><footer><span className="wiki-avatar">P</span>PROMPT Wiki <span>♡</span></footer></Link>)}</div>}{!pages.length&&!['File','Category'].includes(namespace)&&<p>还没有匹配的条目。</p>}<div className="wiki-bottom"><Link href="/articles">浏览已有图文作品 →</Link><a href="/api/wiki-index">AI 内容索引 ↗</a></div></Shell>}
|
||||
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 <Shell><Head><title>PROMPT · 发现</title></Head><div className="discovery-tabs"><Link href="/" className="selected">发现</Link><Link href="/categories">分类</Link></div>{unavailable&&<p role="status">素材库暂时无法连接,先看看这些公开笔记。</p>}<ExploreFeed key={q} initial={entries} cursor={cursor} q={q}/></Shell>}
|
||||
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}}}};
|
||||
|
||||
@@ -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 <Shell><Head><title>{post.title} · PROMPT</title><link rel="alternate" type="text/markdown" href={`/api/wiki-post/${post.slug}`}/></Head><div className="detail-top"><Link href={`/${section.path}`}><ArrowLeft size={17}/>返回{section.name}</Link><span>{['image','video','audio'].includes(post.kind)?'File':'Main'}:{post.slug}</span></div>
|
||||
<div className={`detail-layout ${Work?'coded-detail':''}`}><section className="detail-media">
|
||||
{Work?<ArticleGallery key={post.slug} slug={post.slug}/>:selected?<><div className={`media-stage stage-${selected.kind}`}>
|
||||
{selected.kind==='image'&&<img src={mediaUrl(selected.id)} alt={selected.filename||post.title}/>}
|
||||
{selected.kind==='image'&&<img src={previewUrl(mediaUrl(selected.id),1280)} alt={selected.filename||post.title}/>}
|
||||
{selected.kind==='video'&&<video key={selected.id} controls playsInline preload="metadata" poster={selected.variants.includes('poster')?mediaUrl(selected.id,'poster'):undefined} src={mediaUrl(selected.id,selected.variants.includes('silent_video')?'silent_video':undefined)}/>}
|
||||
{selected.kind==='audio'&&<div className="audio-stage"><div className="vinyl"><span/></div><h3>{selected.filename}</h3><audio key={selected.id} controls preload="metadata" src={mediaUrl(selected.id)}/></div>}
|
||||
{media.length>1&&<><button className="gallery-prev icon-button" disabled={index===0} aria-label="上一个素材" onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><button className="gallery-next icon-button" disabled={index===media.length-1} aria-label="下一个素材" onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><span className="gallery-position">{index+1} / {media.length}</span></>}
|
||||
</div>
|
||||
{selected.kind==='video'&&<div className="video-companion"><div><span className="pill">{selected.has_audio?<><Volume2 size={14}/>原视频有声音</>:<><VolumeX size={14}/>原视频无声音</>}</span><small>{selected.processing_status==='ready'?'画面已分离,可独立预览与复用':selected.processing_status==='failed'?'音画分离未完成,原文件仍可使用':'正在后台拆分画面与声音…'}</small></div>{selected.variants.includes('audio_track')&&<div className="bound-audio"><span><Volume2 size={16}/>这段视频的声音</span><audio controls preload="metadata" src={mediaUrl(selected.id,'audio_track')}/><small>音轨随视频保存,不属于独立声音素材。</small></div>}</div>}
|
||||
{media.length>1&&<div className="thumbnail-strip">{media.map((a,i)=><button key={a.id} className={i===index?'selected':''} onClick={()=>setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'?<img src={mediaUrl(a.id)} alt=""/>:a.variants.includes('poster')?<img src={mediaUrl(a.id,'poster')} alt=""/>:<span>{a.kind==='audio'?'声音':'视频'} {i+1}</span>}</button>)}</div>}</>:<div className="empty-state"><Layers/><p>这个素材包还没有可展示的内容。</p></div>}
|
||||
{media.length>1&&<div className="thumbnail-strip">{media.map((a,i)=><button key={a.id} className={i===index?'selected':''} onClick={()=>setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'?<img src={previewUrl(mediaUrl(a.id),240)} alt=""/>:a.variants.includes('poster')?<img src={previewUrl(mediaUrl(a.id,'poster'),240)} alt=""/>:<span>{a.kind==='audio'?'声音':'视频'} {i+1}</span>}</button>)}</div>}</>:<div className="empty-state"><Layers/><p>这个素材包还没有可展示的内容。</p></div>}
|
||||
</section><aside className="detail-info"><span className="eyebrow">A PIECE OF YOUR COLLECTION</span><h1>{post.title}</h1><div className="detail-date">{new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}<span>· {Work?8:media.length} {Work?'张图':'份素材'}</span></div>
|
||||
<div className="description">{post.assets.filter(a=>a.kind==='text').map(a=><p key={a.id}>{a.body}</p>)}</div>
|
||||
<div className="tag-list">{post.tags.map(t=><Link key={t.id} href={`/${section.path}/${t.path}`}># {t.name}</Link>)}</div>
|
||||
@@ -31,6 +32,4 @@ export default function PostDetail({post}:{post:Post}){
|
||||
</aside></div>
|
||||
</Shell>;
|
||||
}
|
||||
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};};
|
||||
|
||||
@@ -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?<GuidanceIndex/>:<SkillDocs documentPath={documentPath}/>;}
|
||||
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};};
|
||||
|
||||
@@ -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};};
|
||||
|
||||
@@ -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}});
|
||||
|
||||
@@ -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};};
|
||||
|
||||
@@ -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 <PostDetail post={post}/>;if(!page)return null;const key=page.namespace+':'+page.slug;return <Shell><Head><title>{page.title} · PROMPT Wiki</title><link rel="alternate" type="text/markdown" href={'/api/wiki/'+encodeURIComponent(key)}/></Head><article className="wiki-article"><span className="wiki-label">{page.namespace} : {page.slug}</span><h1>{page.title}</h1><p className="wiki-summary">{page.summary}</p><div className="wiki-body">{page.namespace==='Category'?page.body.split('\n\n').map((line,i)=>{const m=line.match(/^\[(.*?)\]\((.*?)\)$/);return m?<p key={i}><Link href={m[2]}>{m[1]} →</Link></p>:null}):page.body.split('\n\n').map((text,i)=><p key={i}>{text}</p>)}</div><footer>{page.categories.map(c=><Link key={c} href={'/wiki/'+encodeURIComponent('Category:'+c)}># {c} </Link>)}<a href={'/api/wiki/'+encodeURIComponent(key)}>读取 Markdown ↗</a></footer></article></Shell>}
|
||||
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}};};
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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<string,string>={};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('<img'));assert.ok(!body.includes('!['));assert.equal(body,entryText((await getEntry(entry.id))!));});
|
||||
test('preview URLs always use bounded image endpoints',()=>{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');});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);});
|
||||
@@ -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()=>{
|
||||
|
||||
@@ -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、纯文本响应、图片体积与缓存、旧文档引用及集成行为。
|
||||
@@ -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:<sha>` 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>`。文档 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`。
|
||||
|
||||
@@ -6,3 +6,5 @@ sharedWorkspaceLockfile: false
|
||||
allowBuilds:
|
||||
sharp: true
|
||||
esbuild: true
|
||||
|
||||
verifyDepsBeforeRun: false
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user