Compare commits

..

4 Commits

Author SHA1 Message Date
codex-bot 8c5d838944 Add infinite canvas workspace with local drafts and connected nodes 2026-10-29 19:50:59 +08:00
Zayden-Jung f490543853 Merge pull request 'Keep image retention policy checks on main only' (#18) from codex/retention-main-only into main
Wiki image retention checks / retention-policy (push) Has been cancelled
Reviewed-on: #18
2026-10-29 11:22:47 +00:00
codex-bot b94c1e43f1 Keep image retention checks on main updates only 2026-10-29 19:02:45 +08:00
Zayden-Jung 9a603cf87e Merge pull request 'Retain wiki rollback images and expire unused CI images' (#17) from codex/wiki-image-retention into main
Wiki image retention checks / retention-policy (push) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 40m26s
Reviewed-on: #17
2026-10-29 10:48:15 +00:00
6 changed files with 69 additions and 11 deletions
@@ -1,13 +1,5 @@
name: Wiki image retention checks
on:
pull_request:
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/deploy.sh'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
push:
branches: [main]
paths:
@@ -16,6 +8,7 @@ on:
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
@@ -0,0 +1,4 @@
// Run against an already started Studio. BASE_URL is the origin, including port.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,35 @@
import Head from 'next/head';
import {useEffect,useRef,useState} from 'react';
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight} from 'lucide-react';
import s from '../../styles/infinite-canvas.module.css';
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string};
type Doc={version:1;cards:Card[];links:[string,string][]};
const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
const key='studio.infinite-canvas.v1';
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
export default function InfiniteCanvas(){
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState('');
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc}|null>(null);
const active=doc.cards.find(c=>c.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})}
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+320))-minX,h=Math.max(...doc.cards.map(c=>c.y+350))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <main className={s.app}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
<section className={s.canvasArea}><div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} ref={viewport} onWheel={e=>{if(e.ctrlKey||e.metaKey){setZoom(z=>Math.max(.2,Math.min(2,z-e.deltaY*.002)))}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{if(e.target===e.currentTarget||tool==='hand'){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}:c)})}} onPointerUp={()=>{const d=drag.current;if(d&&d.id!=='pan'){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{drag.current=null}}>
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+320} ${from.y+150} C ${from.x+365} ${from.y+150},${to.x-45} ${to.y+150},${to.x} ${to.y+150}`}/>})}</svg>
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} style={{left:c.x,top:c.y}} onClick={()=>select(c.id)}><div className={s.cardHead} onPointerDown={e=>{if(tool==='hand')return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><p>{c.text||'选择节点,在右侧写下你的想法。'}</p></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<span className={s.port}/></footer></article>)}
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.2,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
<aside className={s.inspector}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>↗ {doc.cards.find(c=>c.id===(a===selected?b:a))?.title} <span>断开 ×</span></button>)}<button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题移动节点<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
</main>;
}
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
# Infinite canvas plan
Scope: only /studio/infinite_canvas, its CSS module, browser interaction checks. Based on main f490543 in isolated worktree. No homepage, graphic, shared authentication or deployment changes.
## Research before implementation
- Baseline server screenshot captured 2026-10-29: existing server requires dynamic-code authentication before route access. Do not bypass it.
- Internet image search: AI infinite canvas ComfyUI RunningHub interface; reviewed workflow canvas and media card arrangements. No unlicensed product screenshots copied into product.
- https://jimeng.jianying.com — prompt-driven creation, image references, canvas.
- https://klingai.com — attempted primary source; robots denied access. No unsupported detailed claims.
- https://hailuoai.video — reference input and media-first exploration.
- https://www.liblib.art and https://www.liblib.tv — Liblib/LibTV reference targets; client-rendered content limits source inspection.
- https://docs.comfy.org/interface/overview — explicit node workflow and workspace regions.
- https://www.runninghub.cn — canvas-oriented creation and reusable workflows.
- https://github.com/bergside/awesome-design-skills — reference catalogue; apply deliberate typography, spacing, accessible controls and consistent hierarchy, without installing external skills.
## Design and behavior
Dark graphite creation desk with warm lime accent. Narrow left tool rail, large dotted canvas, editable right inspector and floating zoom controls. Start with a clearly labelled sample storyboard: brief → reference → shot plan. Native drag and zoom, selection, editable text/prompt cards, image upload, visible links, add/delete, undo/redo, fit view. Persist bounded browser-local drafts and export/import portable JSON. Images remain local. No simulated generation; generation cards explicitly say model service is not connected. Account link uses existing /account and GET /api/account/session contract.
## Validation
Local TypeScript and existing tests; local build explicitly authorized for this task. Browser checks for desktop/mobile, creation, drag, undo, reload, JSON export and disconnected AI notice. Clean owned screenshots, build directories and temporary dependency links before delivery. Push branch and open PR against main; no merge or deployment.
## Results
TypeScript passed; existing 10 tests passed. Project-local Next CLI webpack build passed. Default Turbopack cannot resolve dependencies outside isolated worktree; webpack standalone tracing warns about the temporary dependency symlinks, so deployment artifact completeness is not claimed. Browser smoke passed on desktop and mobile. No deployment performed.
+5 -3
View File
@@ -54,6 +54,8 @@ the original build/deployment status or rolling back healthy services. Reference
images and Docker refusal are reported and retained. Failed CI does not record a
successful release and does not immediately remove that job's test image.
PR checks run Python policy tests and shell syntax checks on ces-control without
building or touching Docker. The existing full build/integration/deployment runs
only after the user merges to main. No local builds are needed.
After the user merges to main, policy checks run Python tests and shell syntax
checks on ces-control without building or touching Docker. The full application
build/integration/deployment also runs only on main updates, subject to its path
filters. PR events and manual dispatch do not trigger either workflow. No local
builds are needed.