Flatten note tags and store processed media as ordinary resource links
wiki CI and deployment / verify-and-deploy (push) Successful in 13m27s

This commit is contained in:
2026-10-26 00:23:02 +08:00
parent 742b58013e
commit 9986846c36
31 changed files with 255 additions and 209 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/wiki/scripts/container-health.mjs apps/wiki/scripts/wait-snapshots.mjs ./
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/
USER node
+1 -1
View File
@@ -29,7 +29,7 @@ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/wiki/scripts/container-health.mjs apps/wiki/scripts/wait-snapshots.mjs ./
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
COPY --chown=node:node apps/wiki/seed/characters/ ./seed/characters/
USER node
+48
View File
@@ -0,0 +1,48 @@
BEGIN;
-- Preserve references before removing the old screenshot catalogue.
INSERT INTO post_assets(post_id,asset_id,position,role)
SELECT ps.post_id,ps.asset_id,
COALESCE((SELECT max(pa.position)+1 FROM post_assets pa WHERE pa.post_id=ps.post_id),0)
+row_number() OVER(PARTITION BY ps.post_id ORDER BY ps.page_index)::integer-1,'content'
FROM page_snapshots ps WHERE NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=ps.post_id AND pa.asset_id=ps.asset_id);
DROP TABLE render_jobs;
DROP TABLE page_snapshots;
ALTER TABLE posts DROP COLUMN page_key;
-- Every processed file is an ordinary asset, retaining its original storage key.
ALTER TABLE assets ADD COLUMN source_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT;
ALTER TABLE assets ADD COLUMN derivation text CHECK(derivation IN ('silent_video','audio_track','poster'));
ALTER TABLE assets ADD CONSTRAINT assets_derivation_source CHECK((source_asset_id IS NULL)=(derivation IS NULL));
CREATE UNIQUE INDEX assets_source_derivation ON assets(source_asset_id,derivation) WHERE source_asset_id IS NOT NULL;
INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,source_asset_id,derivation)
SELECT md5(v.storage_key)::uuid,CASE v.role WHEN 'poster' THEN 'image' WHEN 'audio_track' THEN 'audio' ELSE 'video' END,
v.role,v.storage_key,v.mime_type,v.byte_size,v.asset_id,v.role FROM asset_variants v WHERE true
ON CONFLICT(storage_key) DO UPDATE SET source_asset_id=excluded.source_asset_id,derivation=excluded.derivation;
ALTER TABLE post_assets DROP CONSTRAINT post_assets_role_check;
ALTER TABLE post_assets ADD CONSTRAINT post_assets_role_check CHECK(role IN ('content','caption','silent_video','audio_track','poster'));
INSERT INTO post_assets(post_id,asset_id,position,role)
SELECT sources.post_id,a.id,
COALESCE((SELECT max(pa.position)+1 FROM post_assets pa WHERE pa.post_id=sources.post_id),0)
+row_number() OVER(PARTITION BY sources.post_id ORDER BY a.id)::integer-1,a.derivation
FROM (SELECT DISTINCT post_id,asset_id FROM post_assets WHERE role='content') sources
JOIN assets a ON a.source_asset_id=sources.asset_id
WHERE NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=sources.post_id AND pa.asset_id=a.id);
DROP TABLE asset_variants;
-- Flatten tags, merging equal names without losing either kind of association.
DROP TRIGGER tags_no_cycles ON tags;
DROP FUNCTION prevent_tag_cycle();
CREATE TEMP TABLE tag_slugs ON COMMIT DROP AS SELECT id,slug,row_number() OVER(PARTITION BY slug ORDER BY id) AS rank FROM tags;
UPDATE tags SET slug='tag-'||replace(id::text,'-','');
UPDATE tags SET parent_id=NULL;
CREATE TEMP TABLE tag_merge ON COMMIT DROP AS
SELECT id,first_value(id) OVER(PARTITION BY lower(btrim(name)) ORDER BY id) AS keep_id FROM tags;
INSERT INTO post_tags(post_id,tag_id) SELECT pt.post_id,m.keep_id FROM post_tags pt JOIN tag_merge m ON m.id=pt.tag_id WHERE true ON CONFLICT DO NOTHING;
INSERT INTO asset_tags(asset_id,tag_id) SELECT at.asset_id,m.keep_id FROM asset_tags at JOIN tag_merge m ON m.id=at.tag_id WHERE true ON CONFLICT DO NOTHING;
DELETE FROM tags t USING tag_merge m WHERE t.id=m.id AND m.id<>m.keep_id;
ALTER TABLE tags DROP COLUMN parent_id;
UPDATE tags t SET slug=CASE WHEN s.rank=1 THEN s.slug ELSE 'tag-'||replace(t.id::text,'-','') END FROM tag_slugs s WHERE s.id=t.id;
CREATE UNIQUE INDEX tags_slug_unique ON tags(slug);
CREATE UNIQUE INDEX tags_name_unique ON tags(lower(btrim(name)));
INSERT INTO schema_migrations(version) VALUES('004_flat_notes');
COMMIT;
-8
View File
@@ -1,8 +0,0 @@
const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];
for(let attempt=0;attempt<120;attempt++){
const states=await Promise.all(slugs.map(async slug=>{const r=await fetch('http://127.0.0.1:3000/api/posts/'+slug+'/snapshots');if(!r.ok)throw Error('Screenshot state unavailable');return r.json()}));
const failed=states.find(s=>s.job?.status==='failed');if(failed)throw Error(failed.job.error||'Screenshot generation failed');
if(states.every(s=>s.job?.status==='ready'&&s.job.revision===process.env.RELEASE_SHA&&s.pages.length===8&&s.pages.every(p=>p.revision===process.env.RELEASE_SHA))){console.log('Verified 24 current server-rendered page snapshots');process.exit(0)}
await new Promise(r=>setTimeout(r,2000));
}
throw Error('Screenshot publication timed out');
@@ -1,14 +0,0 @@
import {previewUrl} from '@/content/page-links';
import Link from "next/link";
import {useEffect,useState} from "react";
import {ChevronLeft,ChevronRight,RefreshCw,ExternalLink} from "lucide-react";
import {mediaUrl} from "@/lib/model";
type State={pages:{page_index:number;asset_id:string;rendered_at:string}[];job:{status:string;error:string|null}|null};
export function ArticleGallery({slug}:{slug:string}){
const [state,setState]=useState<State>({pages:[],job:null});const[index,setIndex]=useState(1);const[error,setError]=useState('');const[requesting,setRequesting]=useState(false);
const pending=state.job?.status==='pending'||state.job?.status==='processing';
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={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>;
}
+2 -2
View File
@@ -2,12 +2,12 @@ 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 {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,ArrowUpRight} 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>})}</>}
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 className="note-inline-link" key={j} href={m[2].replace(/\?agent_mode=True$/,'')}><LinkIcon size={13} aria-hidden="true"/><span>{m[1]}</span>{/^https?:/.test(m[2])&&<ArrowUpRight size={12} aria-hidden="true"/>}</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(()=>{setIndex(0);setCopied(false);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>灵感笔记</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></>}
+1 -1
View File
@@ -29,7 +29,7 @@ function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:b
export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:string;tagPath?:string}){
const router=useRouter();const [posts,setPosts]=useState(initial.posts);const [cursor,setCursor]=useState(initial.nextCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const generation=useRef(0);
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags.filter(t=>target?t.parent_id===target.id:!t.parent_id);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
+13
View File
@@ -0,0 +1,13 @@
import {useState} from 'react';
import type {Tag} from '@/lib/model';
export function TagInput({initial,selected,onChange,disabled=false}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean}){
const [all,setAll]=useState(initial);const [input,setInput]=useState('');const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const name=input.replace(/^#+/,'').trim();
const suggestions=all.filter(t=>!selected.includes(t.id)&&(!name||t.name.toLocaleLowerCase().includes(name.toLocaleLowerCase()))).slice(0,12);
async function add(){if(!name||busy||disabled||selected.length>=50)return;setBusy(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);}}
return <div className="form-field"><label htmlFor="tag-input">标签 <small>输入 #标签,按回车添加</small></label>
<div className="tag-input-selected">{all.filter(t=>selected.includes(t.id)).map(t=><button key={t.id} type="button" disabled={disabled||busy} aria-label={'移除标签 '+t.name} onClick={()=>onChange(selected.filter(id=>id!==t.id))}>#{t.name}<span aria-hidden="true">×</span></button>)}</div>
<div className="tag-input-row"><input id="tag-input" value={input} maxLength={61} disabled={disabled||busy} placeholder="#添加标签" onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!e.nativeEvent.isComposing){e.preventDefault();void add()}}}/><button type="button" className="secondary-button" disabled={!name||busy||disabled||selected.length>=50} onClick={()=>void add()}>{busy?'添加中…':'添加'}</button></div>
<div className="tag-input-suggestions">{suggestions.map(t=><button key={t.id} type="button" disabled={disabled||busy||selected.length>=50} onClick={()=>{onChange([...selected,t.id]);setInput('')}}>#{t.name}</button>)}</div>
{error&&<p role="alert" className="error-message">{error}</p>}</div>;
}
-9
View File
@@ -1,9 +0,0 @@
import type { ComponentType } from "react";
import { LearningSeries } from "./learning-skills/Series";
// Register code-authored pages here, then reference their key in the posts API.
// No page or sample post is inserted into the user's library automatically.
export const works: Record<string, { kind: "article" | "motion"; Component: ComponentType }> = {
"learning-skills-notes": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-notes"/>},
"learning-skills-teaching": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-teaching"/>},
"learning-skills-english": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-english"/>},
};
-1
View File
@@ -9,7 +9,6 @@ export function apiError(res: NextApiResponse,error: unknown) {
const message=error instanceof Error?error.message:"";
const clientMessages:Record<string,string>={
'Character PNG must have transparency':'请上传带真实透明背景的 PNG,透明区域 alpha 为 0,人物内部 alpha 至少为 250',
'Register the coded page before creating its post':'请先在代码中注册对应的作品页面',
'Referenced asset does not exist':'引用的素材不存在',
'A media post needs matching content':'素材包需要包含对应类型的素材',
'Cover must reference an image':'封面需要引用一张图片',
+10
View File
@@ -0,0 +1,10 @@
import type {PoolClient} from 'pg';
// Serialize appending links so a worker and a concurrent editor cannot reuse a position.
export async function linkDerivedAssets(client:PoolClient,postId:string){
await client.query('SELECT id FROM posts WHERE id=$1 FOR UPDATE',[postId]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role)
SELECT $1,a.id,COALESCE((SELECT max(position)+1 FROM post_assets WHERE post_id=$1),0)
+row_number() OVER(ORDER BY a.id)::integer-1,a.derivation
FROM assets a WHERE a.source_asset_id IN (SELECT asset_id FROM post_assets WHERE post_id=$1 AND role='content')
AND NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=$1 AND pa.asset_id=a.id)`,[postId]);
}
+3 -6
View File
@@ -4,8 +4,6 @@ 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;description:string;imageCount:number;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,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(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}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
@@ -15,10 +13,9 @@ 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+' 页'}));}
const media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text'&&a.role==='content').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/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
const body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n')+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
return {id:post.id,title:post.title,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,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.description).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,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.snapshot_count||post.assets.filter(a=>a.kind==='image').length,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 async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,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/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,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\nDescription: ${entry.description}\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';}
+6 -8
View File
@@ -1,10 +1,9 @@
import { db } from "./db";
import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model";
import { works } from "../content/registry";
const tagTree = `WITH RECURSIVE tree AS (SELECT *,slug::text AS path FROM tags WHERE parent_id IS NULL UNION ALL SELECT t.*,tree.path||'/'||t.slug FROM tags t JOIN tree ON t.parent_id=tree.id) SELECT * FROM tree`;
export async function tags(): Promise<Tag[]> { return (await db().query(`${tagTree} ORDER BY path`)).rows; }
const expand = `SELECT p.*,(SELECT count(*)::integer FROM page_snapshots ps WHERE ps.post_id=p.id) AS snapshot_count,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
export async function tags(): Promise<Tag[]> { return (await db().query('SELECT *,slug AS path FROM tags ORDER BY name,id')).rows; }
const expand = `SELECT p.*,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.derivation) FROM assets v WHERE v.source_asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`;
function serialize(posts: Post[], allTags: Tag[]): Post[] {
return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), updated_at:new Date(p.updated_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
@@ -21,10 +20,9 @@ export async function feed(options: {kind?: PostKind; path?: string; cursor?: st
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");
const cursor=decodeCursor(options.cursor); const values: unknown[]=[];
const where=[`((p.kind IN ('article','motion') AND p.page_key=ANY($1::text[])) OR EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role='content' AND a.kind=p.kind AND a.storage_key IS NOT NULL AND a.byte_size>0))`];
values.push(Object.keys(works));
const where=[`EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role IN ('content','caption') AND ((a.storage_key IS NOT NULL AND a.byte_size>0) OR (a.kind='text' AND length(a.body)>0)))`];
if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);}
if(target){values.push(allTags.filter(t=>t.path===target.path||t.path.startsWith(target.path+"/")).map(t=>t.id));where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
if(target){values.push([target.id]);where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
if(options.q){values.push(`%${options.q.replace(/[\\%_]/g,"\\$&")}%`);where.push(`p.title ILIKE $${values.length}`);}
if(cursor){values.push(...cursor);where.push(`(p.created_at,p.id)<($${values.length-1}::timestamptz,$${values.length}::uuid)`);}
const rows=(await db().query(`${expand} WHERE ${where.join(' AND ')} ORDER BY p.created_at DESC,p.id DESC LIMIT 25`,values)).rows as Post[];
+3 -3
View File
@@ -6,9 +6,9 @@ export const sections = [
{ path: "motion", kind: "motion", name: "动态作品", hint: "让网页本身成为时间里的画面" },
] as const;
export type PostKind = typeof sections[number]["kind"];
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[] };
export type Tag = { id: string; parent_id: string | null; name: string; slug: string; path: string };
export type Post = { description: string; snapshot_count: number; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[]; role: string; source_asset_id: string | null; derivation: string | null };
export type Tag = { id: string; name: string; slug: string; path: string };
export type Post = { description: string; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
export function validSection(path: string) { return sections.find(s => s.path === path); }
export const mediaUrl = (id: string, variant?: string) => `/api/media/${id}${variant ? `?variant=${variant}` : ""}`;
+14 -10
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {guidanceDocuments} from "../content/skills/guidance";
import {wikiPages} from "../content/wiki";
import { chapters } from "../content/learning-skills/data";
import { transaction } from "./db";
import { putFile } from "./storage";
@@ -22,23 +24,25 @@ export async function publishLearningSeries() {
await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
VALUES($1,'image','学习资料自己做-系列共用封面.png',$2,'image/png',$3,$4,1080,1440)
ON CONFLICT(id) DO UPDATE SET storage_key=excluded.storage_key,byte_size=excluded.byte_size,sha256=excluded.sha256`,[coverId,storageKey,cover.length,digest]);
const root=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,NULL,'learning-skills','学习工具')
ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId("tag-root")])).rows[0].id;
const root=(await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,'learning-skills','学习工具')
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING id`,[stableId("tag-root")])).rows[0].id;
for(const name of new Set([...guidanceDocuments.map(d=>d.collection),...wikiPages.flatMap(p=>p.categories)])){
await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,$2,$3) ON CONFLICT(lower(btrim(name))) DO NOTHING`,[stableId('tag:'+name),'tag-'+stableId('tag:'+name),name]);
}
const published:string[]=[];
for(const chapter of chapters){
const child=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,$3,$4)
ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId(`tag-${chapter.key}`),root,chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5,$6)
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,description=excluded.description,cover_asset_id=COALESCE((SELECT asset_id FROM page_snapshots WHERE post_id=posts.id AND page_index=1),excluded.cover_asset_id),updated_at=now()
WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId,chapter.subtitle]);
const child=(await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,$2,$3)
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING id`,[stableId(`tag-${chapter.key}`),chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5)
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,description=excluded.description,cover_asset_id=COALESCE(posts.cover_asset_id,excluded.cover_asset_id),updated_at=now()
WHERE posts.id=excluded.id AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,coverId,chapter.subtitle]);
if(!result.rows[0])throw new Error(`Publication slug is already used by another post: ${chapter.slug}`);
const postId=result.rows[0].id;const captionId=stableId(`caption-${chapter.key}`);
const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n${chapter.subtitle}。三篇共用系列封面,每篇 8 页,介绍 10 项工具。\n\n${chapter.skills.map((skill,i)=>`${String(i+1).padStart(2,"0")} ${skill.name} — ${skill.title}`).join("\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。内页小样为排版示意;可在末页访问全部来源。`;
const caption=`学习资料自己做 · 第 ${chapter.number} 辑\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")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。`;
await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2) ON CONFLICT(id) DO UPDATE SET body=excluded.body",[captionId,caption]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'content'),($1,$3,1,'caption')
ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role=excluded.role`,[postId,result.rows[0].cover_asset_id,captionId]);
await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",[postId,child]);
await client.query(`INSERT INTO render_jobs(post_id,status,revision) VALUES($1,'pending',$2) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,attempts=0,target_page=NULL,error=NULL WHERE render_jobs.revision IS DISTINCT FROM excluded.revision`,[postId,process.env.RELEASE_SHA||'development']);
await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2),($1,$3) ON CONFLICT DO NOTHING",[postId,child,root]);
published.push(chapter.slug);
}
return {published,coverId};
-57
View File
@@ -1,57 +0,0 @@
import {chromium} from "playwright-core";
import {createHash,randomUUID} from "node:crypto";
import {mkdtemp,rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {db,transaction} from "./db";
import {putFile} from "./storage";
import {chapters} from "../content/learning-skills/data";
export async function snapshotState(slug:string){
const post=(await db().query('SELECT id,page_key FROM posts WHERE slug=$1',[slug])).rows[0];
if(!post||!chapters.some(c=>c.key===post.page_key))return null;
const pages=(await db().query('SELECT page_index,asset_id,rendered_at,revision FROM page_snapshots WHERE post_id=$1 ORDER BY page_index',[post.id])).rows;
const job=(await db().query('SELECT status,error,revision,attempts,target_page FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null;
return {postId:post.id,pages,job};
}
export async function queueSnapshots(postId:string,pageIndex?:number){
await db().query(`INSERT INTO render_jobs(post_id,status,revision,target_page) VALUES($1,'pending',$2,$3) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,target_page=excluded.target_page,attempts=0,error=NULL WHERE render_jobs.status NOT IN ('pending','processing')`,[postId,process.env.RELEASE_SHA||'development',pageIndex??null]);
}
export async function renderOne(){
const job=await transaction(async c=>(await c.query(`UPDATE render_jobs SET status='processing',attempts=attempts+1,locked_at=now() WHERE post_id=(SELECT post_id FROM render_jobs WHERE status='pending' OR (status='processing' AND locked_at<now()-interval '10 minutes') ORDER BY post_id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)).rows[0]);
if(!job)return false;
const dir=await mkdtemp(join(tmpdir(),'prompt-snapshots-'));let browser;
try{
const post=(await db().query('SELECT slug,page_key FROM posts WHERE id=$1',[job.post_id])).rows[0];
if(!chapters.some(c=>c.key===post.page_key&&c.slug===post.slug))throw Error('Unknown coded page');
browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox','--disable-dev-shm-usage']});
const context=await browser.newContext({viewport:{width:1080,height:1440},deviceScaleFactor:1,locale:'zh-CN',timezoneId:'Asia/Shanghai',reducedMotion:'reduce'});
const origin=new URL(process.env.RENDER_ORIGIN||'http://web:3000').origin;
await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
const page=await context.newPage();const output:{index:number;id:string}[]=[];
for(let index=job.target_page||1;index<=(job.target_page||8);index++){
console.log('Rendering canvas',post.slug,index);
const response=await page.goto(`${origin}/p/${post.slug}/pages/${index}`,{waitUntil:'networkidle',timeout:45000});
if(!response?.ok())throw Error('Canvas page unavailable');
await page.locator('[data-render-page]').waitFor();await page.evaluate(async()=>{await document.fonts.ready;});
console.log('Canvas loaded',post.slug,index);
const overflow=await page.evaluate(()=>{const sheet=document.querySelector('.learning-sheet');if(!sheet)return false;const footer=sheet.querySelector('footer')!;return Array.from(sheet.children).filter(c=>c!==footer).some(c=>c.getBoundingClientRect().bottom>footer.getBoundingClientRect().top-2)});
if(overflow)throw Error(`Canvas ${index} overlaps its footer`);
const path=join(dir,`${index}.png`);const bytes=await page.locator('[data-render-page]').screenshot({path,animations:'disabled'});
const hash=createHash('sha256').update(bytes).digest('hex');const key=`snapshots/${hash}.png`;const size=await putFile(key,path,'image/png');
const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id});console.log('Canvas stored',post.slug,index);
}
await transaction(async c=>{
const current=(await c.query('SELECT revision FROM render_jobs WHERE post_id=$1 FOR UPDATE',[job.post_id])).rows[0];
if(current.revision!==job.revision)return;
for(const result of output){
await c.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,$2,$3,$4) ON CONFLICT(post_id,page_index) DO UPDATE SET asset_id=excluded.asset_id,revision=excluded.revision,rendered_at=now()`,[job.post_id,result.index,result.id,job.revision]);
await c.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'content') ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role='content'`,[job.post_id,result.id,result.index===1?0:result.index+1]);
}
// Position zero remains the shared series cover reference; captions remain at one.
if(output[0].index===1)await c.query('UPDATE posts SET cover_asset_id=$2,updated_at=now() WHERE id=$1',[job.post_id,output[0].id]);
await c.query("UPDATE render_jobs SET status='ready',error=NULL WHERE post_id=$1",[job.post_id]);
});
}catch(e){await db().query("UPDATE render_jobs SET status=$2,error=$3 WHERE post_id=$1 AND revision=$4",[job.post_id,job.attempts<3?'pending':'failed',e instanceof Error?e.message:'Screenshot failed',job.revision]);console.error('Page screenshot job failed',job.post_id,e instanceof Error?e.message:'');await new Promise(r=>setTimeout(r,3000));}
finally{await browser?.close();await rm(dir,{recursive:true,force:true});}
return true;
}
+4 -4
View File
@@ -1,28 +1,28 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { transaction } from "./db";
import { works } from "../content/registry";
import {linkDerivedAssets} from "./asset-links";
export const postInput=z.object({
slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,119}$/).optional(),
kind:z.enum(["image","video","audio","article","motion"]),title:z.string().trim().min(1).max(200),
assets:z.array(z.object({id:z.uuid(),role:z.enum(["content","caption"]).default("content")})).max(100).default([]),
description:z.string().trim().max(1000).default(""),
text:z.string().trim().max(100000).optional(),tags:z.array(z.uuid()).max(50).default([]),
coverAssetId:z.uuid().optional(),pageKey:z.string().max(120).optional(),
coverAssetId:z.uuid().optional(),
});
export async function createPost(input:z.infer<typeof postInput>) {
const id=randomUUID(); const slug=input.slug||id;
if(["article","motion"].includes(input.kind)&&(!input.pageKey||works[input.pageKey]?.kind!==input.kind)) throw new Error("Register the coded page before creating its post");
return transaction(async client=>{
const ids=[...new Set(input.assets.map(a=>a.id))];
const assets=ids.length?(await client.query('SELECT id,kind FROM assets WHERE id=ANY($1::uuid[])',[ids])).rows:[];
if(assets.length!==ids.length) throw new Error("Referenced asset does not exist");
if(["image","video","audio"].includes(input.kind)&&!input.assets.some(link=>link.role==='content'&&assets.some(a=>a.id===link.id&&a.kind===input.kind))) throw new Error("A media post needs matching content");
if(input.coverAssetId && !(await client.query("SELECT 1 FROM assets WHERE id=$1 AND kind='image'",[input.coverAssetId])).rowCount) throw new Error("Cover must reference an image");
await client.query('INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6,$7)',[id,slug,input.kind,input.title,input.pageKey||null,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]);
await client.query('INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6)',[id,slug,input.kind,input.title,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]);
for(const [position,a] of input.assets.entries()) await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[id,a.id,position,a.role]);
if(input.text){const textId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,input.text]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[id,textId,input.assets.length]);}
for(const tag of new Set(input.tags)) await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tag]);
await linkDerivedAssets(client,id);
return {id,slug};
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
const variant=String(req.query.variant||'');
if(variant&&!['poster','silent_video','audio_track'].includes(variant))return res.status(404).end();
const result=variant?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
const result=variant?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
if(!result.rows[0]?.storage_key)return res.status(404).end();
const object=await getObject(result.rows[0].storage_key,req.headers.range);
res.status(object.ContentRange?206:200);
@@ -1,8 +0,0 @@
import type {NextApiRequest,NextApiResponse} from "next";
import {snapshotState,queueSnapshots} from "@/lib/snapshots";
import {sameOrigin,apiError} from "@/lib/api";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(!['GET','POST'].includes(req.method||''))return res.status(405).end();
try{const state=await snapshotState(String(req.query.slug));if(!state)return res.status(404).end();if(req.method==='POST'){sameOrigin(req);const page=req.body?.page;if(page!==undefined&&(!Number.isInteger(page)||page<1||page>8))return res.status(400).json({error:'页码应为 1—8'});await queueSnapshots(state.postId,page);return res.status(202).json({queued:true});}return res.json(state);}catch(e){return apiError(res,e);}
}
+1 -1
View File
@@ -2,5 +2,5 @@ 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 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 assets WHERE source_asset_id=$1 AND derivation=$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}};
+11 -8
View File
@@ -1,15 +1,18 @@
import type { NextApiRequest,NextApiResponse } from "next";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { db } from "@/lib/db";
import { tags } from "@/lib/library";
import { sameOrigin,apiError } from "@/lib/api";
import type {NextApiRequest,NextApiResponse} from 'next';
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db} from '@/lib/db';
import {tags} from '@/lib/library';
import {sameOrigin,apiError} from '@/lib/api';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
if(req.method==='GET')return res.json(await tags());
if(req.method!=='POST')return res.status(405).end();
sameOrigin(req);
const data=z.object({name:z.string().trim().min(1).max(60),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/),parentId:z.uuid().nullable().default(null)}).parse(req.body);
const id=randomUUID(); await db().query('INSERT INTO tags(id,name,slug,parent_id) VALUES($1,$2,$3,$4)',[id,data.name,data.slug,data.parentId]);return res.status(201).json({id});
const data=z.object({name:z.string().trim().transform(v=>v.replace(/^#+/,'').trim()).pipe(z.string().min(1).max(60).regex(/^[^#\s,,]+$/)),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/).optional()}).parse(req.body);
const id=randomUUID();
const result=await db().query(`INSERT INTO tags(id,name,slug) VALUES($1,$2,$3)
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING *,slug AS path`,[id,data.name,data.slug||'tag-'+id]);
return res.status(result.rows[0].id===id?201:200).json(result.rows[0]);
}catch(e){return apiError(res,e);}
}
+4 -3
View File
@@ -3,6 +3,7 @@ import Head from "next/head";
import { useState,type FormEvent } from "react";
import { useRouter } from "next/router";
import { Upload,Image,Film,AudioLines,ArrowUpRight,X,Check } from "lucide-react";
import {TagInput} from "@/components/TagInput";
import { Shell } from "@/components/Shell";
import { tags } from "@/lib/library";
import type { Tag } from "@/lib/model";
@@ -10,15 +11,15 @@ export default function Import({allTags}:{allTags:Tag[]}){
const router=useRouter();const [kind,setKind]=useState(['image','video','audio'].includes(String(router.query.kind))?String(router.query.kind):'image');
const [files,setFiles]=useState<File[]>([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState<string[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
function choose(list:FileList|null){if(!list)return;const items=Array.from(list);setFiles(items);if(!title&&items[0])setTitle(items[0].name.replace(/\.[^.]+$/,''));}
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/p/'+result.slug);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/explore/'+result.id);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
return <Shell><Head><title>素材入库 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
<div className="kind-picker">{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=><button type="button" key={t.id} disabled={busy} className={kind===t.id?'selected':''} onClick={()=>{setKind(t.id);setFiles([]);}}><t.Icon size={19}/>{t.name}</button>)}</div>
<label className="drop-zone" onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();if(!busy)choose(e.dataTransfer.files);}}><input type="file" multiple disabled={busy} accept={kind==='image'?'image/jpeg,image/png,image/webp,image/gif,image/avif':kind+'/*'} onChange={e=>choose(e.target.files)}/><span className="upload-circle"><Upload size={24}/></span><strong>选择文件或拖放到这里</strong><span>最多 16 个文件,单个不超过 250 MB</span></label>
{files.length>0&&<ul className="file-list">{files.map((f,i)=><li key={i}><Check size={15}/><span>{f.name}</span><small>{(f.size/1024/1024).toFixed(1)} MB</small><button type="button" aria-label={`移除 ${f.name}`} disabled={busy} onClick={()=>setFiles(a=>a.filter((_,j)=>i!==j))}><X size={14}/></button></li>)}</ul>}
<div className="form-field"><label htmlFor="title">标题</label><input id="title" required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)} placeholder="添加标题"/></div>
<div className="form-field"><label htmlFor="description">描述 <small>选填</small></label><textarea id="description" maxLength={100000} rows={4} value={description} onChange={e=>setDescription(e.target.value)} placeholder="拍摄场景、灵感、下次想怎么用…"/></div>
{allTags.length>0&&<div className="form-field"><label>放进哪些分类 <small>可多选</small></label><div className="tag-picker">{allTags.map(t=><label key={t.id}><input type="checkbox" checked={selected.includes(t.id)} onChange={e=>setSelected(s=>e.target.checked?[...s,t.id]:s.filter(id=>id!==t.id))}/>{allTags.filter(a=>t.path.startsWith(a.path+'/')).map(a=>a.name+' / ')}{t.name}</label>)}</div></div>}
{kind==='video'&&<p className="form-note">视频入库后会自动分离画面和声音。有声、无声都会标记;分离出的音轨始终跟随原视频。</p>}
<TagInput initial={allTags} selected={selected} onChange={setSelected} disabled={busy}/>
{kind==='video'&&<p className="form-note">视频入库后会自动分离画面和声音。有声、无声都会标记;生成的静音视频、音轨和封面会作为资源链接附在这篇笔记中。</p>}
{error&&<p role="alert" className="error-message">{error}</p>}<div className="form-actions"><span>原文件独立保存,素材包只记录引用。</span><button className="primary-button" disabled={!files.length||busy}>{busy?'正在入库,请稍候…':'导入素材'}<ArrowUpRight size={16}/></button></div>
</form></Shell>;
}
+3 -34
View File
@@ -1,35 +1,4 @@
import {previewUrl} from '@/content/page-links';
import type { GetServerSideProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { useEffect,useState } from "react";
import { useRouter } from "next/router";
import { ArrowLeft,ChevronLeft,ChevronRight,Volume2,VolumeX,Download,Copy,Layers } from "lucide-react";
import { ArticleGallery } from "@/components/ArticleGallery";
import { Shell } from "@/components/Shell";
import { getPost } from "@/lib/library";
import { mediaUrl,sections,type Post } from "@/lib/model";
import { works } from "@/content/registry";
export default function PostDetail({post}:{post:Post}){
const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();
const media=post.assets.filter(a=>a.kind!=='text');const selected=media[index];const section=sections.find(s=>s.kind===post.kind)!;
useEffect(()=>{if(post.assets.some(a=>['pending','processing'].includes(a.processing_status))){const timer=setTimeout(()=>{void router.replace(router.asPath,undefined,{scroll:false});},3000);return ()=>clearTimeout(timer);}},[post,router]);
const Work=post.page_key?works[post.page_key]?.Component:undefined;
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={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={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>
{selected&&<div className="asset-facts"><h3>素材信息</h3><dl><dt>原文件</dt><dd>{selected.filename}</dd>{selected.width&&<><dt>尺寸</dt><dd>{selected.width} × {selected.height}</dd></>}{selected.duration&&<><dt>时长</dt><dd>{selected.duration.toFixed(1)} 秒</dd></>}<dt>复用方式</dt><dd>引用同一素材,无需再次上传</dd></dl><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(selected.id);setCopied(true);setTimeout(()=>setCopied(false),2000);}}><Copy size={14}/>{copied?'已复制素材 ID':'复制素材 ID'}</button><a className="text-link" href={mediaUrl(selected.id)} download><Download size={14}/>打开原文件</a></div>}
</aside></div>
</Shell>;
}
import type {GetServerSideProps} from 'next';
import {getPost} from '@/lib/library';
export default function LegacyPost(){return null;}
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,6 +0,0 @@
import Head from "next/head";
import type {GetServerSideProps} from "next";
import {chapters} from "@/content/learning-skills/data";
import {LearningSeries} from "@/content/learning-skills/Series";
export default function Canvas({chapterKey,page,title}:{chapterKey:string;page:number;title:string}){return <><Head><title>{`${title} · 第 ${page+1} 张`}</title><meta name="robots" content="noindex"/></Head><LearningSeries key={`${chapterKey}-${page}`} chapterKey={chapterKey} pageNumber={page} standalone/></>}
export const getServerSideProps:GetServerSideProps=async({params})=>{const chapter=chapters.find(c=>c.slug===params?.slug);const n=String(params?.page);if(!chapter||!/^([1-8])$/.test(n))return {notFound:true};return {props:{chapterKey:chapter.key,page:Number(n)-1,title:chapter.title}}};
+9
View File
@@ -18,3 +18,12 @@
/* Independent notes: description, topic tags and image count, with no author chrome. */
.discovery-note-link{display:block;color:inherit;text-decoration:none}.discovery-description{font-size:13px;line-height:1.8;color:#777;margin:0 2px 9px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}.discovery-tags{display:flex;gap:5px 12px;flex-wrap:wrap;margin:0 2px;font-size:12px;line-height:1.7;color:#466478}.discovery-tags a:hover{text-decoration:underline}.discovery-cover>.discovery-image-count{position:absolute;z-index:1;right:12px;top:12px;display:flex;align-items:center;gap:5px;padding:4px 8px;border-radius:14px;background:#0007;color:#fff;opacity:1;letter-spacing:0;font-size:12px;line-height:1.4;backdrop-filter:blur(4px)}.note-info>.note-content{padding-top:32px}.note-content>.note-description{color:#7b7b7b;font-size:14px;line-height:1.8;margin:0 0 12px}.note-content>.note-tags{display:flex;gap:6px 12px;flex-wrap:wrap;margin-bottom:25px;font-size:13px}.note-content>.note-tags a{color:#466478}@media(max-width:760px){.discovery-description{font-size:12px;-webkit-line-clamp:3}.discovery-tags{font-size:11px;gap:3px 8px}.discovery-cover>.discovery-image-count{right:9px;top:9px;font-size:11px}.note-info>.note-content{padding-top:24px}}
.note-content a.note-inline-link{display:inline-flex;align-items:baseline;gap:4px;max-width:100%;padding:0 5px;border-radius:5px;background:#f2f5f7;color:#456278;text-decoration:none;vertical-align:baseline;overflow-wrap:anywhere}
.note-inline-link svg{flex-shrink:0;align-self:center;opacity:.65}
.note-content a.note-inline-link:hover{background:#e8eff4;text-decoration:underline;text-underline-offset:3px}
.note-content a.note-inline-link:focus-visible{outline:2px solid #6888a0;outline-offset:2px}
.tag-input-selected,.tag-input-suggestions{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.tag-input-selected button,.tag-input-suggestions button{border:0;background:#f2f3f4;color:#526373;border-radius:16px;padding:6px 10px;cursor:pointer;font-size:13px}
.tag-input-selected span{margin-left:8px;color:#888}
.tag-input-row{display:flex;align-items:center;gap:10px}.tag-input-row input{min-width:0;flex:1}.tag-input-row button{flex-shrink:0}
+9 -3
View File
@@ -8,7 +8,8 @@ import { setTimeout } from "node:timers/promises";
import { db,transaction } from "./lib/db";
import { getObject,putFile } from "./lib/storage";
import { probe,run } from "./lib/probe";
import {renderOne} from "./lib/snapshots";
import {randomUUID} from "node:crypto";
import {linkDerivedAssets} from "./lib/asset-links";
let stopping=false;
process.on('SIGTERM',()=>{stopping=true;});process.on('SIGINT',()=>{stopping=true;});
async function processOne(){
@@ -27,7 +28,12 @@ async function processOne(){
const outputs:{role:string;mime:string;file:string;args:string[];key:string;size:number}[]=[];
for(const v of variants){const path=join(dir,v.file);await run(process.env.FFMPEG_PATH||'ffmpeg',['-nostdin','-y','-v','error','-protocol_whitelist','file,pipe','-i',input,...v.args,path],{timeout:10*60*1000,maxBuffer:2*1024*1024});const key=`derived/${asset.id}/${v.file}`;const size=await putFile(key,path,v.mime);outputs.push({...v,key,size});}
await transaction(async client=>{
for(const v of outputs)await client.query('INSERT INTO asset_variants(asset_id,role,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5) ON CONFLICT(asset_id,role) DO UPDATE SET storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size',[asset.id,v.role,v.key,v.mime,v.size]);
for(const v of outputs)await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,source_asset_id,derivation)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(source_asset_id,derivation) WHERE source_asset_id IS NOT NULL
DO UPDATE SET storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size`,
[randomUUID(),v.role==='poster'?'image':v.role==='audio_track'?'audio':'video',v.file,v.key,v.mime,v.size,asset.id,v.role]);
const posts=(await client.query("SELECT DISTINCT post_id FROM post_assets WHERE asset_id=$1 AND role='content' ORDER BY post_id",[asset.id])).rows;
for(const post of posts)await linkDerivedAssets(client,post.post_id);
await client.query("UPDATE assets SET has_audio=$2,processing_status='ready',processing_error=NULL WHERE id=$1",[asset.id,hasAudio]);
await client.query("UPDATE media_jobs SET status='ready',last_error=NULL WHERE id=$1",[job.id]);
});
@@ -44,7 +50,7 @@ async function processOne(){
async function main(){
const heartbeat=()=>writeFile('/tmp/prompt-worker-health',String(Date.now())).catch(()=>console.error('Worker heartbeat failed'));
await heartbeat();const timer=setInterval(()=>{void heartbeat();},15000);
try{while(!stopping){try{if(!await processOne()&&!await renderOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
try{while(!stopping){try{if(!await processOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
finally{clearInterval(timer);await db().end();}
}
main().catch(()=>process.exit(1));
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {randomUUID} from 'node:crypto';
import {db} from '../src/lib/db';
export async function verifyFlatNoteMigration(){
const client=await db().connect();const schema='migration_'+randomUUID().replaceAll('-','');
try{
await client.query(`CREATE SCHEMA ${schema}`);await client.query(`SET search_path TO ${schema}`);
for(const name of ['001_library','002_page_snapshots','003_note_descriptions'])await client.query(await readFile(`db/${name}.sql`,'utf8'));
const [video,picture,post,tag,duplicate,parent]=Array.from({length:6},()=>randomUUID());
await client.query(`INSERT INTO assets(id,kind,storage_key,mime_type,byte_size) VALUES($1,'video','original','video/mp4',100),($2,'image','snapshot','image/png',50)`,[video,picture]);
await client.query(`INSERT INTO posts(id,slug,kind,title,page_key) VALUES($1,'old-note','article','保留的笔记','old-code')`,[post]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,0)`,[post,video]);
await client.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,1,$2,'old-revision')`,[post,picture]);
await client.query(`INSERT INTO render_jobs(post_id,status,revision) VALUES($1,'pending','old-revision')`,[post]);
await client.query(`INSERT INTO asset_variants(asset_id,role,storage_key,mime_type,byte_size) VALUES($1,'poster','old-poster','image/jpeg',20)`,[video]);
await client.query(`INSERT INTO media_jobs(id,asset_id) VALUES($1,$2)`,[randomUUID(),video]);
await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,'parent','上级'),($2,'same','重复')`,[parent,tag]);
await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,'same','重复')`,[duplicate,parent]);
await client.query(`INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2),($1,$3)`,[post,tag,duplicate]);
await client.query(`INSERT INTO asset_tags(asset_id,tag_id) VALUES($1,$2)`,[video,duplicate]);
await client.query(await readFile('db/004_flat_notes.sql','utf8'));
assert.equal((await client.query('SELECT count(*)::integer n FROM post_assets WHERE post_id=$1 AND asset_id=$2',[post,picture])).rows[0].n,1,'Previously generated image remains linked');
const processed=(await client.query(`SELECT a.storage_key,pa.role FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.derivation='poster'`,[post])).rows;
assert.deepEqual(processed,[{storage_key:'old-poster',role:'poster'}],'Processed file uses its existing object and a normal resource link');
assert.equal((await client.query('SELECT count(*)::integer n FROM post_tags WHERE post_id=$1',[post])).rows[0].n,1);
assert.equal((await client.query('SELECT count(*)::integer n FROM asset_tags WHERE asset_id=$1',[video])).rows[0].n,1,'Merging equal tags retains asset associations');
assert.equal((await client.query('SELECT count(*)::integer n FROM media_jobs')).rows[0].n,1,'Video processing queue survives');
assert.equal((await client.query(`SELECT count(*)::integer n FROM information_schema.columns WHERE table_schema=$1 AND column_name IN ('parent_id','page_key')`,[schema])).rows[0].n,0);
}finally{
await client.query('ROLLBACK');await client.query('SET search_path TO public');await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);client.release();
}
}
+17 -15
View File
@@ -11,9 +11,10 @@ import {randomUUID} from 'node:crypto';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
import {verifyFlatNoteMigration} from './flat-note-migration';
import {publishLearningSeries} from '../src/lib/publish-learning-series';
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:300000}, async()=>{
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:300000}, async()=>{
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
const external=process.env.PROMPT_TEST_BASE_URL;
@@ -24,14 +25,16 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
try{
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white',parentId:root.id});
await assert.rejects(db().query('UPDATE tags SET parent_id=$1 WHERE id=$2',[child.id,root.id]),/cycle/);
await verifyFlatNoteMigration();
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
assert.equal((await db().query("SELECT count(*)::integer AS n FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='tags' AND column_name='parent_id'")).rows[0].n,0);
const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer();
const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();};
const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]);
const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',description:'同一素材的独立说明',assets:[{id:first.assets[0]}],tags:[root.id]});
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2);
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,2);assert.equal(filtered.posts.find((p:{id:string})=>p.id===reused.id).description,'同一素材的独立说明');assert.equal((await request('/api/feed?section=images&path=lines/white')).posts.length,1);
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,1);assert.equal(filtered.posts.find((p:{id:string})=>p.id===reused.id).description,'同一素材的独立说明');assert.equal((await request('/api/feed?section=images&path=white')).posts.length,1);
const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2);
assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules');
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
@@ -44,10 +47,13 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
const post=await upload('video',[{data:await readFile(path),name:'video.mp4',mime:'video/mp4'}]);let asset;
for(let i=0;i<120;i++){asset=(await db().query('SELECT * FROM assets WHERE id=$1',[post.assets[0]])).rows[0];if(asset.processing_status==='ready'||asset.processing_status==='failed')break;await setTimeout(500);}
assert.equal(asset.processing_status,'ready',asset.processing_error);assert.equal(asset.has_audio,sound);
const variants:string[]=(await db().query('SELECT role FROM asset_variants WHERE asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
const variants:string[]=(await db().query('SELECT derivation AS role FROM assets WHERE source_asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
const linked=(await request('/api/feed?section=videos')).posts.find((p:{id:string})=>p.id===post.id);assert.ok(linked.assets.some((a:{role:string})=>a.role==='poster'));
const videoNote=await fetch(base+'/explore/'+post.id+'?agent_mode=True').then(r=>r.text());assert.ok(videoNote.includes('静音视频'));
const reusedVideo=await request('/api/posts',{kind:'video',title:'复用已加工的视频',assets:[{id:asset.id}]});assert.ok((await db().query("SELECT 1 FROM post_assets WHERE post_id=$1 AND role='silent_video'",[reusedVideo.id])).rowCount);
const clip:Response=await fetch(base+'/api/media/'+asset.id+'?variant=silent_video');assert.equal(clip.status,200);const derived=join(dir,'derived.mp4');await writeFile(derived,Buffer.from(await clip.arrayBuffer()));assert.equal((await probe(derived)).streams.some(s=>s.codec_type==='audio'),false);
}
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio'")).rows[0].n,0,'Bound soundtracks are variants, not independent audio assets');
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio' AND derivation='audio_track'")).rows[0].n,1,'Video soundtracks are ordinary reusable assets');
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
const published=await publishLearningSeries();await publishLearningSeries();
@@ -61,16 +67,12 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440);
assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events');
for(const slug of published.published){
for(const n of [1,3,8]){const r=await fetch(base+`/p/${slug}/pages/${n}`);assert.equal(r.status,200);const html=await r.text();assert.ok(html.includes('data-render-page'));assert.ok(!html.includes('global-topbar'));}
let status;for(let i=0;i<480;i++){status=await request(`/api/posts/${slug}/snapshots`);if(status.job?.status==='failed')assert.fail(status.job.error);if(status.job?.status==='ready'&&status.pages.length===8)break;await setTimeout(500)}
assert.equal(status.pages.length,8,'Screenshot job state: '+JSON.stringify(status.job));
const r:Response=await fetch(base+'/api/media/'+status.pages[2].asset_id);const meta:{width?:number;height?:number}=await sharp(Buffer.from(await r.arrayBuffer())).metadata();assert.equal(meta.width,1080);assert.equal(meta.height,1440);
assert.equal((await fetch(base+`/api/posts/${slug}/snapshots`)).status,404);
assert.equal((await fetch(base+`/p/${slug}/pages/1`)).status,404);
}
const notes=await request('/api/explore-feed');for(const slug of published.published){const post=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===slug);assert.equal(notes.entries.find((e:{id:string})=>e.id===post.id).imageCount,8);}
const before=await request(`/api/posts/${published.published[0]}/snapshots`);
const refresh=await fetch(base+`/api/posts/${published.published[0]}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({page:3})});assert.equal(refresh.status,202);
let after;for(let i=0;i<80;i++){after=await request(`/api/posts/${published.published[0]}/snapshots`);if(after.job?.status==='ready'&&after.pages[2].rendered_at!==before.pages[2].rendered_at)break;await setTimeout(500)}
assert.notEqual(after.pages[2].rendered_at,before.pages[2].rendered_at);assert.equal(after.pages[1].rendered_at,before.pages[1].rendered_at,'Single-page refresh preserves other snapshots');
const article=await request('/api/posts',{kind:'article',title:'无代码页面的笔记',text:'普通正文与 [引用](https://example.com)'});
const articlePage=await fetch(base+'/explore/'+article.id).then(r=>r.text());assert.ok(articlePage.includes('note-inline-link'));
const remaining=(await db().query("SELECT to_regclass('asset_variants') AS variants,to_regclass('page_snapshots') AS snapshots,to_regclass('render_jobs') AS renders")).rows[0];assert.deepEqual(remaining,{variants:null,snapshots:null,renders:null});
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){
const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# '));
+35
View File
@@ -0,0 +1,35 @@
# 扁平笔记、标签与统一资源:变更说明
本阶段保留 posts.slug(旧链接与导入去重)、cover_asset_id(明确封面)、media_jobs(异步视频处理)及 contribution_events。删除 page_key、tags.parent_id、asset_variants、page_snapshots、render_jobs;原文件不删除,旧截图和加工文件转换为普通 assets/post_assets 引用。所有笔记平等,不引入附属文档关系。
## 文件与目的
- `apps/wiki/db/004_flat_notes.sql`:新增事务迁移。保存旧截图引用,将加工文件转换为普通资源,合并同名标签且保留笔记/资源关联,再移除旧字段和表。
- `apps/wiki/src/lib/model.ts`:更新扁平标签和资源来源字段,移除代码页绑定与截图数量字段。
- `apps/wiki/src/lib/library.ts`:取消递归标签查询和代码页注册过滤;从普通资源计算加工类型,允许直接保存文字笔记。
- `apps/wiki/src/lib/asset-links.ts`:新增共享视频资源的加工结果关联逻辑,锁住笔记以避免并发追加占用同一位置。
- `apps/wiki/src/lib/write.ts`:创建笔记不再依赖 page_key,复用视频时补齐已有加工文件引用。
- `apps/wiki/src/worker.ts`:仅保留视频任务,FFmpeg 输出进入普通资源表并关联到使用该视频的笔记,继续支持重试。
- `apps/wiki/src/lib/publish-learning-series.ts`:发布完整文字和扁平标签,保留已有图片和封面,不再排截图任务;同步静态文档中可见的标签名称。
- `apps/wiki/src/lib/explore.ts`:直接从普通资源展示笔记,图片计数排除视频加工文件;正文中提供加工资源链接,不再读取截图状态或代码页面。
- `apps/wiki/src/lib/api.ts`:移除“必须注册代码页面”的旧错误映射。
- `apps/wiki/src/pages/api/media/[id].ts`、`apps/wiki/src/pages/api/preview.ts`:直接读取普通加工资源,同时兼容已有 variant 媒体地址,继续提供小文件预览。
- `apps/wiki/src/pages/api/tags.ts`:名称全局唯一,支持带 # 创建;同名请求复用已有标签,不需要 parentId 或手写 slug。
- `apps/wiki/src/components/TagInput.tsx`:新增 #标签输入、回车创建、现有标签建议和移除交互。
- `apps/wiki/src/pages/library/import.tsx`:接入标签输入,成功后直接进入 ID 地址,更新视频处理提示。
- `apps/wiki/src/components/ExploreDetail.tsx`:正文链接增加小图标和轻量行内样式。
- `apps/wiki/src/styles/globals.css`:新增行内链接和标签输入样式及焦点反馈。
- `apps/wiki/src/components/Feed.tsx`:旧列表组件改用平级标签,不再寻找子分类。
- `apps/wiki/src/pages/p/[slug].tsx`:精简为兼容旧地址的跳转,移除失效的旧详情渲染代码。
- 删除 `apps/wiki/src/lib/snapshots.ts`、`apps/wiki/src/components/ArticleGallery.tsx`、`apps/wiki/src/pages/api/posts/[slug]/snapshots.ts`、`apps/wiki/src/pages/p/[slug]/pages/[page].tsx`、`apps/wiki/scripts/wait-snapshots.mjs`:移除截图执行器、轮询界面、任务 API、画布路由与部署等待脚本。
- 删除 `apps/wiki/src/content/registry.tsx`:移除 Wiki 代码作品注册绑定。
- `Dockerfile`、`apps/wiki/Dockerfile`:不再复制截图等待脚本,保留现有依赖层缓存。
- `scripts/deploy.sh`:停止写入后备份,迁移失败时恢复兼容旧镜像的数据库,再恢复服务;删除截图等待与共享封面假设。
- `apps/wiki/tests/flat-note-migration.ts`:新增隔离数据库 schema 的真实迁移验证,覆盖旧图片、视频文件、同名标签关联及保留的视频任务。
- `apps/wiki/tests/integration.test.ts`:改为验证平级标签复用、无代码绑定的文字笔记、普通视频加工资源引用及已移除的截图入口。
- `docs/monorepo.md`:更新存储、迁移、媒体引用与截图流程退役说明。
- `docs/flat-notes-stage.md`:本阶段逐文件变更说明。
## 验证
本地 TypeScript 检查通过,18 项单元测试通过,部署脚本语法与 diff 空白检查通过。完整 PostgreSQL/S3 迁移、视频加工、生产构建与上线验证由 Wiki CI 执行。Studio 和 Python 源码未修改。
+10 -2
View File
@@ -1,6 +1,6 @@
# PROMPT monorepo
- `apps/wiki`: existing Pages Router application, media library, PostgreSQL, object storage and snapshot worker. `/wiki/Namespace:slug` has matching `/api/wiki/Namespace:slug` Markdown; `/api/wiki-index` discovers pages. Existing `/p/slug` has `/api/wiki-post/slug` and alternate metadata. Main/File/Category/Template/Draft/Help are public content types, not permission boundaries. Categories cross namespaces; template pages currently describe reusable structures rather than a transclusion engine.
- `apps/wiki`: existing Pages Router application, media library, PostgreSQL, object storage and video processing worker. `/wiki/Namespace:slug` has matching `/api/wiki/Namespace:slug` Markdown; `/api/wiki-index` discovers pages. Existing `/p/slug` has `/api/wiki-post/slug` and alternate metadata. Main/File/Category/Template/Draft/Help are public content types, not permission boundaries. Categories cross namespaces; template pages currently describe reusable structures rather than a transclusion engine.
- `apps/studio`: independent Next.js + TypeScript + Tailwind Pages Router. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering.
- `apps/python`: install with `pip install -e apps/python`. Run `prompt-export idea.json --output ./output --font /path/to/cjk.ttf [--video]`. Generates 1080×1440 PNGs and an optional silent MP4 (4 seconds per slide), requiring FFmpeg. JSON describes standard slides; arbitrary React animations need a separate browser recording pipeline. Python Docker image includes CJK fonts and FFmpeg.
@@ -18,10 +18,18 @@ CI uses root build context and application-scoped COPY instructions. Wiki runs i
## Explore 页面与轻量图片
首页 `/` 与分类 `/categories` 统一浏览;内容固定地址 `/explore/<id>`。文档 ID 持久保存在 `apps/wiki/src/content/page-ids.json`,改标题或位置时保留 ID;数据库素材使用原有 UUID。旧 `/skills/...`、`/p/...` 和 `/wiki/...` 网页跳转到内容地址,导出画布与 API 保持兼容。`?agent_mode=True`(大小写不敏感)在相同地址返回 `text/plain`,保留全部正文与媒体链接。
首页 `/` 与分类 `/categories` 统一浏览;内容固定地址 `/explore/<id>`。文档 ID 持久保存在 `apps/wiki/src/content/page-ids.json`,改标题或位置时保留 ID;数据库素材使用原有 UUID。旧 `/skills/...`、`/p/...` 和 `/wiki/...` 网页跳转到内容地址,媒体与纯文本 API 保持兼容;Wiki 不再提供截图画布与截图任务。`?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`。
## 扁平标签与资源引用
`posts` 保留稳定 UUID、兼容 slug、标题、description、类型、封面资源 ID 与时间;移除代码页绑定 `page_key`。`tags` 为全局唯一名称,不含 `parent_id`,输入 `#标签` 并回车可创建或复用;`post_tags` 和 `asset_tags` 仍是多对多关联。静态 Wiki 页面目前仍使用代码内容,发布步骤将其可见标签同步到标签表。
所有文件统一进入 `assets`。视频加工结果通过 `source_asset_id` 和 `derivation` 记录来源,并以 `post_assets` 的 `silent_video`、`audio_track`、`poster` 角色供笔记引用;它们可以像其他资源一样复用。旧 `?variant=` 媒体地址继续读取相应普通资源。保留 `media_jobs` 用于异步处理和失败重试,保留 `contribution_events`,不再使用 `asset_variants`、`page_snapshots`、`render_jobs`。已有截图转换成普通图片引用后保留。创作站与 Python 导出功能独立运行。
部署在停止 Wiki 写入后备份数据库,再运行迁移和启动新版;失败时使用同一备份恢复旧版所需的数据库结构。对象文件和持久卷保留。
+16 -3
View File
@@ -40,11 +40,21 @@ if command -v systemctl >/dev/null; then
if systemctl is-enabled --quiet prompt-worker.service 2>/dev/null; then LEGACY_WORKER_ENABLED=1; fi
fi
CUTOVER=0
MIGRATION_STARTED=0
BACKUP_FILE=""
rollback(){
local code=$?
trap - ERR
set +e
if [ "$CUTOVER" -eq 1 ]; then
compose stop web worker
if [ "$MIGRATION_STARTED" -eq 1 ] && [ -s "$BACKUP_FILE" ]; then
if ! compose exec -T postgres pg_restore -U prompt -d prompt --clean --if-exists --exit-on-error < "$BACKUP_FILE"; then
echo 'Database rollback failed; application remains stopped. Backup retained.'
exit "$code"
fi
echo 'Restored the database backup compatible with the previous application'
fi
if [ -n "$PREVIOUS" ] && [ -f "$PREVIOUS/compose.yml" ]; then
compose_at "$PREVIOUS" up -d --no-build web worker
echo 'Restored the previous Docker application image'
@@ -69,8 +79,12 @@ trap rollback ERR
# Keep the same prompt_postgres_data and prompt_objects volumes. No production down -v.
compose up -d --no-build --wait --wait-timeout 120 postgres objects
install -d -o root -g root -m 0700 /var/lib/prompt/backups
compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump"
CUTOVER=1
compose stop web worker
BACKUP_FILE="/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump"
compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "$BACKUP_FILE"
find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm --
MIGRATION_STARTED=1
compose run --rm --no-deps migrate
compose run --rm --no-deps publish
CUTOVER=1
@@ -79,11 +93,10 @@ if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then system
if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi
compose up -d --no-build --wait --wait-timeout 180 web worker
compose exec -T web node container-health.mjs
compose exec -T web node wait-snapshots.mjs
compose exec -T web node -e "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
compose exec -T web node scripts/import-character-packs.mjs
compose exec -T worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
compose exec -T web node -e "(async()=>{const feed=await fetch('http://127.0.0.1:3000/api/feed?section=articles').then(r=>r.json());const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];for(const slug of slugs){const post=feed.posts.find(p=>p.slug===slug);if(!post)throw Error('Published article missing: '+slug);const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug)}if(new Set(feed.posts.filter(p=>slugs.includes(p.slug)).map(p=>p.cover_asset_id)).size!==1)throw Error('Series must share one cover');console.log('Verified three published article pages and their shared cover')})().catch(e=>{console.error(e.message);process.exit(1)})"
compose exec -T web node -e "(async()=>{const feed=await fetch('http://127.0.0.1:3000/api/feed?section=articles').then(r=>r.json());const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];for(const slug of slugs){const post=feed.posts.find(p=>p.slug===slug);if(!post)throw Error('Published article missing: '+slug);const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug)}console.log('Verified three published article pages')})().catch(e=>{console.error(e.message);process.exit(1)})"
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
compose ps