From d072aeeaa0b96ef1b1403a68c7e2a1c6345f8cbd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:12:47 +0800 Subject: [PATCH] Model canvas references, script nodes and agent task snapshots --- apps/studio/src/lib/canvas-document.ts | 64 +++++++++++++++++++ .../src/pages/infinite_canvas/index.tsx | 8 +-- apps/studio/tests/canvas-document.test.mjs | 23 +++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 apps/studio/src/lib/canvas-document.ts create mode 100644 apps/studio/tests/canvas-document.test.mjs diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts new file mode 100644 index 0000000..29c6fba --- /dev/null +++ b/apps/studio/src/lib/canvas-document.ts @@ -0,0 +1,64 @@ +export type NodeKind = 'text' | 'image' | 'prompt' | 'script'; +export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number}; +export type CanvasNode = { + id:string;kind:NodeKind;title:string;text:string;x:number;y:number; + image?:string;width?:number;height?:number;style?:TextStyle; + operation?:'generate'|'upscale'|'script'; +}; +export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]}; +const kinds:NodeKind[]=['text','image','prompt','script']; +const object=(v:unknown):v is Record=>!!v&&typeof v==='object'&&!Array.isArray(v); +const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max; +const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max; +const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v); + +export function parseCanvasDocument(value:unknown):CanvasDocument { + const d=typeof value==='string'?JSON.parse(value):value; + if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确'); + const ids=new Set(); + const cards:CanvasNode[]=d.cards.map((n:unknown)=>{ + if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确'); + if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确'); + if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确'); + let style:TextStyle|undefined; + if(n.style!==undefined){ + if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确'); + style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})}; + } + ids.add(n.id as string); + return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})}; + }); + const pairs=new Set(); + const links:[string,string][]=d.links.map((l:unknown)=>{ + if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); + const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; + }); + return {version:1,cards,links}; +} + +export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{ + return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean); +} +export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument { + if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点'); + if(doc.links.some(([a,b])=>a===from&&b===to))return doc; + if(doc.links.length>=200)throw Error('最多 200 条引用'); + const seen=new Set();const pending=[to]; + while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));} + return {...doc,links:[...doc.links,[from,to]]}; +} +export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { + return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))}; +} +export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { + if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); + const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id); + return parseCanvasDocument(result); +} +export function agentTask(doc:CanvasDocument,id:string){ + const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); + if(!node.text.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求'); + const references=referencedNodes(doc,id); + if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片'); + return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction:node.text,references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}}; +} diff --git a/apps/studio/src/pages/infinite_canvas/index.tsx b/apps/studio/src/pages/infinite_canvas/index.tsx index 9666c5a..8e8acc6 100644 --- a/apps/studio/src/pages/infinite_canvas/index.tsx +++ b/apps/studio/src/pages/infinite_canvas/index.tsx @@ -2,17 +2,15 @@ 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,Sun,Moon} 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;width?:number;height?:number}; -type Doc={version:1;cards:Card[];links:[string,string][]}; +import {parseCanvasDocument,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document'; const example: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 initial:Doc={version:1,cards:[],links:[]}; 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.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!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(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState([]),[future,setFuture]=useState([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark'),[editing,setEditing]=useState(''); const file=useRef(null),json=useRef(null),viewport=useRef(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|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 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]); + useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}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]); useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]); useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]); @@ -38,6 +36,6 @@ export default function InfiniteCanvas(){
● {status}
{Math.round(zoom*100)}%
{menu&&
{active?active.title:'添加到画布'}
{active?<>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=>)}:<>}
} {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)}}/> - {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('无法导入:项目格式错误或文件过大')}}}/> + {const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/> ; } diff --git a/apps/studio/tests/canvas-document.test.mjs b/apps/studio/tests/canvas-document.test.mjs new file mode 100644 index 0000000..7c0fd99 --- /dev/null +++ b/apps/studio/tests/canvas-document.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts'; +const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50}; +const doc={version:1,cards:[note],links:[]}; +test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{ + assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc); + for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad)); +}); +test('derived image and script nodes preserve directed references across save and restore',()=>{ + const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'}; + const next=deriveNode(doc,'brief',picture); + const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'}))); + assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture'); + assert.throws(()=>connectNodes(restored,'script','brief'),/循环/); + const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2); +}); +test('agent task captures current referenced content and validates upscale inputs',()=>{ + const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'}); + assert.equal(agentTask(next,'out').references[0].text,'清晨的城市'); + assert.equal(agentTask(next,'out').result.targetId,'out'); + assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/); +});