From de30ee4ec8ea0686d98554bab117e071fa5db409 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 21:02:21 +0800 Subject: [PATCH 1/4] Render saved Studio results directly by ID without an editor --- .gitea/workflows/studio.yml | 3 -- apps/studio/scripts/smoke.mjs | 13 ++++++--- apps/studio/src/components/NativeResult.tsx | 28 +++++++++++++++++++ apps/studio/src/lib/native-layout.ts | 2 +- apps/studio/src/pages/api/layouts/[id].ts | 2 +- apps/studio/src/pages/layouts/[layoutId].tsx | 13 +++++---- apps/studio/src/pages/template-layout.tsx | 18 ++++++++++++ .../src/lib/studio-design-documentation.ts | 5 ++-- .../src/lib/studio-design-posts.ts | 6 ++-- apps/waterfall-wiki/tests/integration.test.ts | 2 +- content/studio-tools/layouts-api.md | 19 ++++++++++--- scripts/publish-studio-library.mjs | 12 ++++++-- scripts/verify-studio-library.mjs | 4 ++- scripts/verify-studio-visual.cjs | 8 +++++- 14 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 apps/studio/src/components/NativeResult.tsx create mode 100644 apps/studio/src/pages/template-layout.tsx diff --git a/.gitea/workflows/studio.yml b/.gitea/workflows/studio.yml index ae2b792..8325218 100644 --- a/.gitea/workflows/studio.yml +++ b/.gitea/workflows/studio.yml @@ -34,9 +34,6 @@ jobs: - name: Release studio if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') run: bash scripts/deploy-studio.sh "${{ gitea.sha }}" - - name: Capture deployed baseline diagnostics (does not validate PR code) - if: gitea.event_name == 'pull_request' - run: bash scripts/verify-studio-production.sh --diagnose - name: Verify the deployed Studio API and native pages if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') run: bash scripts/verify-studio-production.sh diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index 5a72740..c374cab 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -1,17 +1,22 @@ /** CI-only isolated runtime checks, never a local preview workflow. */ import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises'; const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002'; +const standardId='00000000-0000-4000-8000-000000000003'; let reads=0; -const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))}); +const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))}); wiki.listen(0,'127.0.0.1');await once(wiki,'listening'); const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port; try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));assert.ok(library.includes('ref-27'));const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28); -for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render '}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));} +for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render '}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));} assert.equal(reads,0,'Default pages never load a saved design'); const headers={cookie:'session=smoke-test'}; -const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image')); +for(const id of [designId,standardId]){ +const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml)); +} +for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404); assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404); assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404); const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid); -const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/layouts/ref-01?id='+designId); +const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId); +const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId); assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))} diff --git a/apps/studio/src/components/NativeResult.tsx b/apps/studio/src/components/NativeResult.tsx new file mode 100644 index 0000000..e03f080 --- /dev/null +++ b/apps/studio/src/components/NativeResult.tsx @@ -0,0 +1,28 @@ +import Head from 'next/head'; +import type {CSSProperties} from 'react'; +import type {AddedSlot,Layout,Parameters} from '../lib/layout-parameters'; +import {CurvedCopy} from './NativeCurvedCopy'; +import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout'; + +/** A saved result contains artwork only: no editing state, controls or event handlers. */ +export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){ + const params:Parameters=design?.parameters||emptyParameters(); + const elements=[...layout.elements,...(params.added||[])]; + return
+ {design?.name||layout.name} · Prompt +
+ {['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{ + const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{}; + if(ls.hidden||(kind==='decoration'&&d?.hidden))return null; + const text=t?.value??defaultText(layout,s); + const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)}; + const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}; + return
+ {kind==='text'?(s.direction==='curved'?:
{text}
):kind==='image'?
+ {im?.assetId&&'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId!,width:String(width)})+' '+width+'w').join(',')} sizes={`(max-width: 860px) ${Math.min(100,(ls.width??s.box[2])/layout.width*100)}vw, ${Math.min(860,(ls.width??s.box[2])/layout.width*860)}px`} alt={s.role} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>} +
:
{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<>}
} +
; + }))} +
+
; +} diff --git a/apps/studio/src/lib/native-layout.ts b/apps/studio/src/lib/native-layout.ts index e44c158..9422cdf 100644 --- a/apps/studio/src/lib/native-layout.ts +++ b/apps/studio/src/lib/native-layout.ts @@ -5,7 +5,7 @@ export type Design={id:string;name:string;layoutId:string;parameters:Parameters; // getRandomValues is available on the deployment's HTTP origin; randomUUID is not. export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join(''); export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}}); -export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`; +export const designUrl=(_layoutId:string,id:string)=>`/studio/template-layout?id=${id}`; export function nativeLayout(layout:Layout):Layout { return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))}; } diff --git a/apps/studio/src/pages/api/layouts/[id].ts b/apps/studio/src/pages/api/layouts/[id].ts index 11b4eae..cd9370e 100644 --- a/apps/studio/src/pages/api/layouts/[id].ts +++ b/apps/studio/src/pages/api/layouts/[id].ts @@ -15,7 +15,7 @@ const workspaceSchema={ }; export default async function handler(req:NextApiRequest,res:NextApiResponse){ res.setHeader('Cache-Control','no-store'); - if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)} + if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。保存结果直接打开 /studio/template-layout?id={designId},服务端根据 ID 读取模板、图片和文字,无需选版或编辑器;缺失或不存在的 ID 返回404。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开返回的 /studio/template-layout?id={id}(纯结果页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)} if(req.query.id==='standard-01'){ if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()} try{const p=parameters(standardLayout,req.method==='POST'?req.body:{}); diff --git a/apps/studio/src/pages/layouts/[layoutId].tsx b/apps/studio/src/pages/layouts/[layoutId].tsx index 15bfaff..8ce0979 100644 --- a/apps/studio/src/pages/layouts/[layoutId].tsx +++ b/apps/studio/src/pages/layouts/[layoutId].tsx @@ -1,19 +1,20 @@ import type {GetServerSideProps} from 'next'; -import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench'; +import {NativeResult} from '../../components/NativeResult'; +import type {Layout} from '../../lib/layout-parameters'; import fixedLayouts from '../../content/templates/layouts.json'; import standardLayout from '../../content/standard-layout.json'; const layouts=[...fixedLayouts,standardLayout]; -import {nativeLayout} from '../../lib/native-layout'; +import {nativeLayout,designUrl} from '../../lib/native-layout'; import {readDesign} from '../../lib/design-server'; -export default function LayoutPage(props:LayoutPageProps){return } -export const getServerSideProps:GetServerSideProps=async({params,query,req,res})=>{ +export default function LayoutPage({layout}:{layout:Layout}){return } +export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{ res.setHeader('Cache-Control','private, no-store'); const layout=layouts.find(l=>l.id===params?.layoutId); if(!layout)return {notFound:true}; if(query.id!==undefined){ const design=await readDesign(query.id,req); if(!design||design.layoutId!==layout.id)return {notFound:true}; - return {props:{layout:nativeLayout(layout),design}}; + return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}}; } - return {props:{layout:nativeLayout(layout),design:null}}; + return {props:{layout:nativeLayout(layout)}}; }; diff --git a/apps/studio/src/pages/template-layout.tsx b/apps/studio/src/pages/template-layout.tsx new file mode 100644 index 0000000..02ea6be --- /dev/null +++ b/apps/studio/src/pages/template-layout.tsx @@ -0,0 +1,18 @@ +import type {GetServerSideProps} from 'next'; +import {NativeResult} from '../components/NativeResult'; +import fixedLayouts from '../content/templates/layouts.json'; +import standardLayout from '../content/standard-layout.json'; +import {readDesign} from '../lib/design-server'; +import {nativeLayout,type Design} from '../lib/native-layout'; +import type {Layout} from '../lib/layout-parameters'; + +type Props={layout:Layout;design:Design}; +export default function TemplateLayout(props:Props){return } +export const getServerSideProps:GetServerSideProps=async({query,req,res})=>{ + res.setHeader('Cache-Control','private, no-store'); + const design=await readDesign(query.id,req); + if(!design)return {notFound:true}; + const layout=[standardLayout,...fixedLayouts].find(l=>l.id===design.layoutId); + if(!layout)return {notFound:true}; + return {props:{layout:nativeLayout(layout),design}}; +}; diff --git a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts index fd1fe49..9bdba92 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts @@ -1,3 +1,2 @@ -import type {NextApiRequest,NextApiResponse} from 'next'; -export const designDocs="# Studio 排版 API · 标准图文与 27 套固定构图\n\n把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。\n\n[打开可视化 API 库](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)\n\n## 两类排版,同一内容模型\n\n- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。\n- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。\n\n两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。用户可按需进入现有编辑工作区,工具箱功能保持可用。\n\n## 1. 发现能力并选择模板\n\n以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。\n\n```sh\nBASE=http://8.211.154.134:18131\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/standard-01\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/ref-17\"\n```\n\n目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。\n\n## 2. 生成图片,上传一次,显示压缩衍生图\n\n先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n -F kind=image -F assetOnly=true -F files=@hero.png \"$BASE/api/upload\"\n```\n\n返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。\n\n原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n \"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960\" -o hero-preview.webp\n```\n\nwidth 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。\n\n## 3. 创建:POST /api/studio/designs\n\n创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。\n\n```sh\ncurl -fsS -X POST \"$BASE/api/studio/designs\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"schemaVersion\":1,\"layoutId\":\"standard-01\",\"name\":\"观察日常\",\"parameters\":{\"paper\":\"#edf0ef\",\"texts\":{\"T01\":{\"value\":\"观察日常\\n记录灵感\",\"fontFamily\":\"serif\",\"color\":\"#23424b\"},\"T02\":{\"value\":\"让每一页只讲一件事。\",\"lineHeight\":1.4}},\"images\":{\"I01\":{\"color\":\"#73929a\",\"fit\":\"cover\",\"cropX\":50,\"cropY\":45,\"zoom\":1}},\"decorations\":{\"D01\":{\"color\":\"#73929a\"}}}}'\n```\n\n有图片时在 I01 加 `assetId:\"上传返回的UUID\"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。\n\n201 返回 `{design}`。design 包含:\n\n| 字段 | 含义 |\n| --- | --- |\n| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |\n| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |\n| name / parameters | 名称与规范化内容;省略的分组返回空对象 |\n| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |\n| schemaUrl | 当前模板参数地址 |\n| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |\n| archived / archivedAt | 可恢复归档状态与时间 |\n| createdAt / updatedAt | 创建与最后修改时间 |\n| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |\n\n创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。\n\n## 4. 读取与打开\n\n```sh\nID=创建返回的UUID\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs/$ID\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs?layoutId=standard-01&limit=20\"\n```\n\n读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。\n\n直接打开返回的 url:`/studio/layouts/standard-01?id={UUID}` 或 `/studio/layouts/ref-17?id={UUID}`,默认看到原生页面,点击“编辑页面”才进入工作区。`/studio/designs/{UUID}` 是兼容跳转。ID 与模板不匹配返回404。归档设计仍能经 API 读取以恢复,渲染页返回404。\n\n## 5. 更新:PATCH /api/studio/designs/{id}\n\n先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:\n\n```sh\ncurl -fsS -X PATCH \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"观察日常 · 修订\",\"revision\":1,\"parameters\":{\"texts\":{\"T01\":{\"value\":\"新的标题\"}},\"images\":{},\"decorations\":{}}}'\n```\n\n200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。\n\n## 6. 删除与恢复\n\nDELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。\n\n```sh\ncurl -fsS -X DELETE \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":2}'\n# 删除后读取新 revision,再恢复。此例删除后为3:\ncurl -fsS -X POST \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":3,\"archived\":false}'\n```\n\n两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。\n\n## 参数清单(两类共用)\n\nparameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。\n\n| 分组 | 字段与约束 |\n| --- | --- |\n| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |\n| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |\n| decorations.{装饰ID} | color;hidden 布尔值 |\n| paper | 纸张颜色 |\n| layers.{元素ID} | x/y -10000–10000;width/height 0.1–10000;rotation -360–360;opacity 0–1;radius 0–5000;z -1000–1000;hidden/locked 布尔值 |\n| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |\n\n新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。\n\n错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。\n\n## 现有工具箱与兼容导出\n\n工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。\n\n旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。\n\n```sh\ncurl -fsS -X POST \"$BASE/studio/api/export/learning-skills-template?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"slide\":1,\"heading\":\"笔记复习\",\"color\":\"#7a8b7e\"}' -o cover.svg\ncurl -fsS -X POST \"$BASE/studio/api/export/layout-selection?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"layoutId\":\"ref-01\",\"fields\":{\"T01\":\"STUDY\"},\"color\":\"#83918a\"}' -o selection.svg\n```\n\n固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。\n\n## 固定排版索引\n\n读取 /studio/api/layouts 获取全部27套构图。\n"; -export function studioDesignDocumentation(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')||String(req.query.agent_mode).toLowerCase()!=='true')return false;res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')res.status(200).end();else res.status(200).send(designDocs);return true} +// Agent documentation mirrors the canonical Studio guide. +export const designDocs="# Studio 排版 API · 标准图文与 27 套固定构图\n\n把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。\n\n[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)\n\n## agent 的交付流程\n\n1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。\n2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。\n3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。\n4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。\n\n不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。\n\n## 两类排版,同一内容模型\n\n- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。\n- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。\n\n两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。\n\n## 1. 由 agent 通过 API 发现模板\n\n以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。\n\n```sh\nBASE=http://8.211.154.134:18131\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/standard-01\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/ref-17\"\n```\n\n目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。\n\n## 2. 生成图片,上传一次,显示压缩衍生图\n\n先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n -F kind=image -F assetOnly=true -F files=@hero.png \"$BASE/api/upload\"\n```\n\n返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。\n\n原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n \"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960\" -o hero-preview.webp\n```\n\nwidth 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。\n\n## 3. 创建:POST /api/studio/designs\n\n创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。\n\n```sh\ncurl -fsS -X POST \"$BASE/api/studio/designs\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"schemaVersion\":1,\"layoutId\":\"standard-01\",\"name\":\"观察日常\",\"parameters\":{\"paper\":\"#edf0ef\",\"texts\":{\"T01\":{\"value\":\"观察日常\\n记录灵感\",\"fontFamily\":\"serif\",\"color\":\"#23424b\"},\"T02\":{\"value\":\"让每一页只讲一件事。\",\"lineHeight\":1.4}},\"images\":{\"I01\":{\"color\":\"#73929a\",\"fit\":\"cover\",\"cropX\":50,\"cropY\":45,\"zoom\":1}},\"decorations\":{\"D01\":{\"color\":\"#73929a\"}}}}'\n```\n\n有图片时在 I01 加 `assetId:\"上传返回的UUID\"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。\n\n201 返回 `{design}`。design 包含:\n\n| 字段 | 含义 |\n| --- | --- |\n| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |\n| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |\n| name / parameters | 名称与规范化内容;省略的分组返回空对象 |\n| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |\n| schemaUrl | 当前模板参数地址 |\n| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |\n| archived / archivedAt | 可恢复归档状态与时间 |\n| createdAt / updatedAt | 创建与最后修改时间 |\n| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |\n\n创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。\n\n## 4. 读取与打开\n\n```sh\nID=创建返回的UUID\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs/$ID\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs?layoutId=standard-01&limit=20\"\n```\n\n读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。\n\n直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。\n\n`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。\n\n## 5. 更新:PATCH /api/studio/designs/{id}\n\n先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:\n\n```sh\ncurl -fsS -X PATCH \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"观察日常 · 修订\",\"revision\":1,\"parameters\":{\"texts\":{\"T01\":{\"value\":\"新的标题\"}},\"images\":{},\"decorations\":{}}}'\n```\n\n200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。\n\n## 6. 删除与恢复\n\nDELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。\n\n```sh\ncurl -fsS -X DELETE \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":2}'\n# 删除后读取新 revision,再恢复。此例删除后为3:\ncurl -fsS -X POST \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":3,\"archived\":false}'\n```\n\n两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。\n\n## 参数清单(两类共用)\n\nparameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。\n\n| 分组 | 字段与约束 |\n| --- | --- |\n| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |\n| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |\n| decorations.{装饰ID} | color;hidden 布尔值 |\n| paper | 纸张颜色 |\n| layers.{元素ID} | x/y -10000–10000;width/height 0.1–10000;rotation -360–360;opacity 0–1;radius 0–5000;z -1000–1000;hidden/locked 布尔值 |\n| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |\n\n新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。\n\n错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。\n\n## 现有工具箱与兼容导出\n\n工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。\n\n旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。\n\n```sh\ncurl -fsS -X POST \"$BASE/studio/api/export/learning-skills-template?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"slide\":1,\"heading\":\"笔记复习\",\"color\":\"#7a8b7e\"}' -o cover.svg\ncurl -fsS -X POST \"$BASE/studio/api/export/layout-selection?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"layoutId\":\"ref-01\",\"fields\":{\"T01\":\"STUDY\"},\"color\":\"#83918a\"}' -o selection.svg\n```\n\n固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。\n\n## 固定排版索引\n\n读取 /studio/api/layouts 获取全部27套构图。\n"; diff --git a/apps/waterfall-wiki/src/lib/studio-design-posts.ts b/apps/waterfall-wiki/src/lib/studio-design-posts.ts index f116eea..f998c00 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-posts.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-posts.ts @@ -3,8 +3,8 @@ import type {PoolClient} from 'pg'; import type {Parameters} from './layout-parameters'; export type DesignRow={id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;archived?:boolean;created_at:Date;updated_at:Date}; -export const studioPageUrl=(design:Pick)=>`/studio/layouts/${design.layout_id}?id=${design.id}`; -function postDescription(design:DesignRow){return `已保存的 ${design.layout_id} 排版页面,可直接打开并继续编辑。`} +export const studioPageUrl=(design:Pick)=>`/studio/template-layout?id=${design.id}`; +function postDescription(design:DesignRow){return `已保存的 ${design.layout_id} 排版结果,打开链接即显示图片与文字。`} /** Caller holds the design row lock. All writes share the design's transaction. */ export async function ensureDesignPost(client:PoolClient,design:DesignRow):Promise{ if(design.post_id){ @@ -14,7 +14,7 @@ export async function ensureDesignPost(client:PoolClient,design:DesignRow):Promi } const postId=randomUUID(),textId=randomUUID(),imageIds=[...new Set(Object.values(design.parameters.images||{}).flatMap(image=>image.assetId?[image.assetId]:[]))]; const images=imageIds.length?(await client.query("SELECT id FROM assets WHERE id=ANY($1::uuid[]) AND kind='image'",[imageIds])).rows.map(row=>row.id as string):[]; - const body=`[打开页面并继续编辑](${studioPageUrl(design)})\n\n排版:${design.layout_id}\n\n页面 ID:\`${design.id}\`\n\n此笔记关联独立保存的页面;页面内容以链接中的保存记录为准。`; + const body=`[打开排版结果](${studioPageUrl(design)})\n\n排版:${design.layout_id}\n\n页面 ID:\`${design.id}\`\n\n此笔记关联 API 保存的模板、图片与文字;直接打开链接即可看到结果,无需选择排版或进入编辑器。`; await client.query("INSERT INTO posts(id,slug,kind,title,description,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5)",[postId,'studio-design-'+design.id,design.name,postDescription(design),images[0]||null]); await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,body]); await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'caption')",[postId,textId]); diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 345da6e..8dbf77d 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -59,7 +59,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404); assert.equal((await db().query("SELECT 1 FROM schema_migrations WHERE version='009_studio_designs'")).rowCount,1,'Studio migration must be recorded for repeat deployments'); // Studio owns no second database: designs use this same isolated PostgreSQL. - for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/layouts/'+layoutId+'?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});} + for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/template-layout?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});} const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}}); const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision}; const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]); diff --git a/content/studio-tools/layouts-api.md b/content/studio-tools/layouts-api.md index 0987f9c..91493b8 100644 --- a/content/studio-tools/layouts-api.md +++ b/content/studio-tools/layouts-api.md @@ -2,16 +2,25 @@ 把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。 -[打开可视化 API 库](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19) +[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19) + +## agent 的交付流程 + +1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。 +2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。 +3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。 +4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。 + +不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。 ## 两类排版,同一内容模型 - **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。 - **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。 -两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。用户可按需进入现有编辑工作区,工具箱功能保持可用。 +两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。 -## 1. 发现能力并选择模板 +## 1. 由 agent 通过 API 发现模板 以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。 @@ -82,7 +91,9 @@ curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs?lay 读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。 -直接打开返回的 url:`/studio/layouts/standard-01?id={UUID}` 或 `/studio/layouts/ref-17?id={UUID}`,默认看到原生页面,点击“编辑页面”才进入工作区。`/studio/designs/{UUID}` 是兼容跳转。ID 与模板不匹配返回404。归档设计仍能经 API 读取以恢复,渲染页返回404。 +直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。 + +`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。 ## 5. 更新:PATCH /api/studio/designs/{id} diff --git a/scripts/publish-studio-library.mjs b/scripts/publish-studio-library.mjs index 1701aae..891eb0e 100644 --- a/scripts/publish-studio-library.mjs +++ b/scripts/publish-studio-library.mjs @@ -14,9 +14,15 @@ const {entry:current,tags}=await api('/api/notes/'+canonical),{entry:old}=await // Snapshot only non-secret editorial state. Retain the first backup across repeat publication. for(const [id,entry] of [[canonical,current],[legacy,old]]){try{await readFile(dir+'/'+id+'-before.json')}catch{await writeFile(dir+'/'+id+'-before.json',JSON.stringify(entry),{mode:0o600})}} const catalogue=await api('/studio/api/layouts'); -let markdown=(await readFile(dir+'/layouts-api.md','utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n')); -const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown; -markdown+='\n\n## 兼容工具原说明与代码示例\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal; +let markdown=(await readFile(new URL('../content/studio-tools/layouts-api.md',import.meta.url),'utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n')); +const appendixMarker='## 兼容工具原说明与代码示例'; +if(current.editableMarkdown.includes(appendixMarker)){ + markdown+='\n\n'+current.editableMarkdown.slice(current.editableMarkdown.indexOf(appendixMarker)); +}else{ + if(old.archived)throw Error('Original toolbox appendix is missing; refusing to replace it with the archived redirect'); + const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown; + markdown+='\n\n'+appendixMarker+'\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal; +} const tagIds=entry=>[...new Set([...(entry.systemTags||[]).map(t=>t.id),...entry.categories.map(name=>tags.find(t=>t.name===name)?.id).filter(Boolean)])]; const body=(entry,text,title=entry.title)=>({title,description:entry.description,markdown:text,tags:tagIds(entry),assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision}); await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 排版 API · 标准图文与27套固定构图'),description:'coding agent 经 HTTP 选择标准图文或27套固定构图,上传压缩展示素材,创建、读取、更新、归档与恢复稳定 ID 原生页面;含兼容工具箱。'},'PUT'); diff --git a/scripts/verify-studio-library.mjs b/scripts/verify-studio-library.mjs index 99579b5..1f4e0b9 100644 --- a/scripts/verify-studio-library.mjs +++ b/scripts/verify-studio-library.mjs @@ -19,6 +19,7 @@ if(process.argv.includes('--finalize')){ const final={canonical:base+'/explore/'+canonical,documentEntries:category?.total??null,legacyCompatible:!documentFailure,testRecordsArchived:results.length}; await writeFile('/tmp/studio-library/final-verification.json',JSON.stringify(final,null,2));console.log(JSON.stringify(final));if(documentFailure)throw documentFailure; }else{ +for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid'])assert.equal((await fetch(base+path)).status,404); const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28); const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry; const asset=note.coverAssetId||note.assets.find(a=>a.kind==='image').id; @@ -29,10 +30,11 @@ for(const layoutId of ['standard-01','ref-17']){ const p={texts:{T01:{value:'API 验收 · 原生页面',fontFamily:'serif',fontWeight:600}},images:{I01:{assetId:asset,fit:'cover',cropX:35,cropY:65,zoom:1.2}},added:[{id:'user-api-check',kind:'text',role:'扩展正文',box:[10,120,60,9],direction:'horizontal',text:'通过 HTTP 保存并恢复',reading:'custom'}],layers:{'user-api-check':{z:20,rotation:2}}}; let d=(await api('/api/studio/designs',{schemaVersion:1,layoutId,name:'API 验收 '+layoutId,parameters:p},'POST',201)).design; const id=d.id,postId=d.postId; + assert.equal(d.url,'/studio/template-layout?id='+id);assert.equal(d.renderUrl,d.url); results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p}); await writeFile('/tmp/studio-library/verification.json',JSON.stringify({results,preview:{asset,bytes,type:'image/webp'},pages:0},null,2)); assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters); - const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/')); + const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(html)); d=(await api('/api/studio/designs/'+id,{name:'API 验收修订 '+layoutId,parameters:p,revision:d.revision},'PATCH')).design; assert.equal(d.postId,postId);assert.equal(d.revision,2); await api('/api/studio/designs/'+id,{name:'过期写入',parameters:{},revision:1},'PATCH',409); diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index beb8608..9faea52 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -26,7 +26,13 @@ const assert=require('node:assert/strict'); await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png',style:'.page-tools{visibility:hidden}'}); - await page.getByRole('button',{name:'编辑页面',exact:true}).click();assert.equal(await page.locator('.workbench-top').count(),1);await page.screenshot({path:dir+'/'+d.layoutId+'-editor.png'}); + assert.equal(new URL(page.url()).pathname,'/studio/template-layout'); + assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.page-tools,.workbench-top').count(),0); + await page.locator('.native-copy').first().dblclick();await page.locator('.native-copy').first().dispatchEvent('contextmenu'); + assert.equal(await page.locator('button,input,textarea,select,[contenteditable],.workbench-top').count(),0); + const plain=await browser.newContext({viewport:{width:1100,height:1000},javaScriptEnabled:false}); + await plain.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});return route.abort()}); + const direct=await plain.newPage();await direct.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await direct.locator('article[data-design-id="'+d.id+'"]').count(),1);assert.ok((await direct.locator('article').innerText()).includes('API 验收'));await plain.close(); } for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); From df831a15ba949e8ba972c6d5589a9ecd6d9ef3a0 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 21:05:04 +0800 Subject: [PATCH 2/4] Distinguish historical toolbox examples from current result flow --- scripts/publish-studio-library.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/publish-studio-library.mjs b/scripts/publish-studio-library.mjs index 891eb0e..f8422ea 100644 --- a/scripts/publish-studio-library.mjs +++ b/scripts/publish-studio-library.mjs @@ -16,12 +16,14 @@ for(const [id,entry] of [[canonical,current],[legacy,old]]){try{await readFile(d const catalogue=await api('/studio/api/layouts'); let markdown=(await readFile(new URL('../content/studio-tools/layouts-api.md',import.meta.url),'utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n')); const appendixMarker='## 兼容工具原说明与代码示例'; +const historicalNotice='> 以下保留历史工具示例,不代表当前 API 保存与打开流程。历史文字中关于首页、编辑器和旧结果地址的描述已过时:当前由 API 创建 ID,直接打开 `/studio/template-layout?id={UUID}`,无需选版或编辑器。当前契约以上文为准。'; if(current.editableMarkdown.includes(appendixMarker)){ - markdown+='\n\n'+current.editableMarkdown.slice(current.editableMarkdown.indexOf(appendixMarker)); + const appendix=current.editableMarkdown.slice(current.editableMarkdown.indexOf(appendixMarker)); + markdown+='\n\n'+(appendix.includes(historicalNotice)?appendix:appendix.replace(appendixMarker,appendixMarker+'\n\n'+historicalNotice)); }else{ if(old.archived)throw Error('Original toolbox appendix is missing; refusing to replace it with the archived redirect'); const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown; - markdown+='\n\n'+appendixMarker+'\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal; + markdown+='\n\n'+appendixMarker+'\n\n'+historicalNotice+'\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal; } const tagIds=entry=>[...new Set([...(entry.systemTags||[]).map(t=>t.id),...entry.categories.map(name=>tags.find(t=>t.name===name)?.id).filter(Boolean)])]; const body=(entry,text,title=entry.title)=>({title,description:entry.description,markdown:text,tags:tagIds(entry),assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision}); From eac2dd4cde1153e071ce38cfe96ec1dc7669e094 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 21:07:53 +0800 Subject: [PATCH 3/4] Expect ID-only result links for both layout families --- apps/studio/tests/native-layout.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/tests/native-layout.test.mjs b/apps/studio/tests/native-layout.test.mjs index 924cb84..b4e3f29 100644 --- a/apps/studio/tests/native-layout.test.mjs +++ b/apps/studio/tests/native-layout.test.mjs @@ -12,7 +12,8 @@ test('geometric templates retain their distinct silhouettes without bitmap masks assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%'); assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon(')); assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath); - assert.equal(designUrl('ref-01','abc'),'/studio/layouts/ref-01?id=abc'); + assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc'); + assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc'); }); test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i Date: Wed, 28 Oct 2026 21:17:04 +0800 Subject: [PATCH 4/4] Preserve the Studio agent documentation response handler --- apps/waterfall-wiki/src/lib/studio-design-documentation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts index 9bdba92..60b492a 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts @@ -1,2 +1,5 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; // Agent documentation mirrors the canonical Studio guide. export const designDocs="# Studio 排版 API · 标准图文与 27 套固定构图\n\n把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。\n\n[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)\n\n## agent 的交付流程\n\n1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。\n2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。\n3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。\n4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。\n\n不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。\n\n## 两类排版,同一内容模型\n\n- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。\n- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。\n\n两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。\n\n## 1. 由 agent 通过 API 发现模板\n\n以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。\n\n```sh\nBASE=http://8.211.154.134:18131\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/standard-01\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/studio/api/layouts/ref-17\"\n```\n\n目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。\n\n## 2. 生成图片,上传一次,显示压缩衍生图\n\n先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n -F kind=image -F assetOnly=true -F files=@hero.png \"$BASE/api/upload\"\n```\n\n返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。\n\n原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:\n\n```sh\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \\\n \"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960\" -o hero-preview.webp\n```\n\nwidth 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。\n\n## 3. 创建:POST /api/studio/designs\n\n创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。\n\n```sh\ncurl -fsS -X POST \"$BASE/api/studio/designs\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"schemaVersion\":1,\"layoutId\":\"standard-01\",\"name\":\"观察日常\",\"parameters\":{\"paper\":\"#edf0ef\",\"texts\":{\"T01\":{\"value\":\"观察日常\\n记录灵感\",\"fontFamily\":\"serif\",\"color\":\"#23424b\"},\"T02\":{\"value\":\"让每一页只讲一件事。\",\"lineHeight\":1.4}},\"images\":{\"I01\":{\"color\":\"#73929a\",\"fit\":\"cover\",\"cropX\":50,\"cropY\":45,\"zoom\":1}},\"decorations\":{\"D01\":{\"color\":\"#73929a\"}}}}'\n```\n\n有图片时在 I01 加 `assetId:\"上传返回的UUID\"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。\n\n201 返回 `{design}`。design 包含:\n\n| 字段 | 含义 |\n| --- | --- |\n| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |\n| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |\n| name / parameters | 名称与规范化内容;省略的分组返回空对象 |\n| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |\n| schemaUrl | 当前模板参数地址 |\n| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |\n| archived / archivedAt | 可恢复归档状态与时间 |\n| createdAt / updatedAt | 创建与最后修改时间 |\n| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |\n\n创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。\n\n## 4. 读取与打开\n\n```sh\nID=创建返回的UUID\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs/$ID\"\ncurl -fsS -H \"Authorization: Bearer $PROMPT_TOKEN\" \"$BASE/api/studio/designs?layoutId=standard-01&limit=20\"\n```\n\n读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。\n\n直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。\n\n`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。\n\n## 5. 更新:PATCH /api/studio/designs/{id}\n\n先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:\n\n```sh\ncurl -fsS -X PATCH \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"观察日常 · 修订\",\"revision\":1,\"parameters\":{\"texts\":{\"T01\":{\"value\":\"新的标题\"}},\"images\":{},\"decorations\":{}}}'\n```\n\n200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。\n\n## 6. 删除与恢复\n\nDELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。\n\n```sh\ncurl -fsS -X DELETE \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":2}'\n# 删除后读取新 revision,再恢复。此例删除后为3:\ncurl -fsS -X POST \"$BASE/api/studio/designs/$ID\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' -d '{\"revision\":3,\"archived\":false}'\n```\n\n两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。\n\n## 参数清单(两类共用)\n\nparameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。\n\n| 分组 | 字段与约束 |\n| --- | --- |\n| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |\n| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |\n| decorations.{装饰ID} | color;hidden 布尔值 |\n| paper | 纸张颜色 |\n| layers.{元素ID} | x/y -10000–10000;width/height 0.1–10000;rotation -360–360;opacity 0–1;radius 0–5000;z -1000–1000;hidden/locked 布尔值 |\n| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |\n\n新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。\n\n错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。\n\n## 现有工具箱与兼容导出\n\n工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。\n\n旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。\n\n```sh\ncurl -fsS -X POST \"$BASE/studio/api/export/learning-skills-template?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"slide\":1,\"heading\":\"笔记复习\",\"color\":\"#7a8b7e\"}' -o cover.svg\ncurl -fsS -X POST \"$BASE/studio/api/export/layout-selection?format=svg\" \\\n -H \"Authorization: Bearer $PROMPT_TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"layoutId\":\"ref-01\",\"fields\":{\"T01\":\"STUDY\"},\"color\":\"#83918a\"}' -o selection.svg\n```\n\n固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。\n\n## 固定排版索引\n\n读取 /studio/api/layouts 获取全部27套构图。\n"; + +export function studioDesignDocumentation(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')||String(req.query.agent_mode).toLowerCase()!=='true')return false;res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')res.status(200).end();else res.status(200).send(designDocs);return true}