From b1e60839960fef369abaa7a657085066fbd376ff Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 17:50:26 +0800 Subject: [PATCH] Connect canvas editing to scoped external agent tasks --- .gitea/workflows/creative-workspace.yml | 14 ++++++-- apps/studio/scripts/infinite-canvas-smoke.cjs | 2 +- .../components/canvas/CanvasAgentPanel.tsx | 32 +++++++++++++++++++ .../src/components/canvas/CanvasComposer.tsx | 6 ++-- apps/studio/src/lib/canvas-document.ts | 2 +- apps/studio/src/pages/api/index.ts | 2 +- .../src/pages/infinite_canvas/index.tsx | 7 ++-- .../studio/src/styles/canvas-agent.module.css | 1 + .../src/styles/canvas-composer.module.css | 2 ++ apps/studio/tests/canvas-document.test.mjs | 5 +++ .../src/lib/api-documentation.ts | 2 +- .../waterfall-wiki/src/lib/canvas-document.ts | 2 +- apps/waterfall-wiki/src/lib/canvas-store.ts | 2 +- .../tests/canvas-integration.ts | 7 ++++ apps/waterfall-wiki/tests/integration.test.ts | 2 +- 15 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 apps/studio/src/components/canvas/CanvasAgentPanel.tsx create mode 100644 apps/studio/src/styles/canvas-agent.module.css diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index ca7fc48..56ee7af 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -12,7 +12,7 @@ concurrency: jobs: verify: runs-on: ces-control - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: @@ -21,5 +21,13 @@ jobs: run: git diff --check HEAD^ HEAD - name: Verify Studio types, tests, build and smoke run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . - - name: Verify Wiki types, tests, build and smoke - run: docker build --target build -f apps/waterfall-wiki/Dockerfile -t "creative-wiki-review:${{ gitea.sha }}" . + - name: Build Wiki integration and runtime images + run: | + docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" . + docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" . + - name: Verify isolated PostgreSQL, S3 and browser integration + env: + WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }} + WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/integration.sh diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index 997bd6b..e7a2b6a 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -46,7 +46,7 @@ const fs=require('node:fs/promises'); await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image); await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click(); const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale); - const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出 Agent 任务',exact:true}).click(); + const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click(); const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8')); assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image); diff --git a/apps/studio/src/components/canvas/CanvasAgentPanel.tsx b/apps/studio/src/components/canvas/CanvasAgentPanel.tsx new file mode 100644 index 0000000..78af9e7 --- /dev/null +++ b/apps/studio/src/components/canvas/CanvasAgentPanel.tsx @@ -0,0 +1,32 @@ +import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react'; +import {parseCanvasDocument,type CanvasDocument} from '../../lib/canvas-document'; +import s from '../../styles/canvas-agent.module.css'; +type Task={id:string;targetId:string;status:'queued'|'running'|'succeeded'|'failed';error?:string}; +type State={id:string;name:string;revision:number;document:CanvasDocument;tasks:Task[]}; +type LinkState={expiresAt:string;lastSeenAt:string|null}; +export type CanvasAgentHandle={queue:(target:string)=>Promise}; +type Props={document:CanvasDocument;email:string;onLoad:(document:CanvasDocument)=>void;onFeedback:(text:string)=>void}; +async function api(path:string,method='GET',body?:unknown){const r=await fetch('/api/canvases'+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'在线画布暂时不可用');return data;} +const names={queued:'等待 Agent',running:'Agent 正在处理',succeeded:'已完成',failed:'已结束'}; +export default forwardRef(function CanvasAgentPanel(p,ref){ + const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[remote,setRemote]=useState(null),[pending,setPending]=useState(null),[connection,setConnection]=useState(null),[credential,setCredential]=useState(''),[items,setItems]=useState<{id:string;name:string}[]>([]); + const live=useRef(p.document),synced=useRef(''),current=useRef(null),locked=useRef(false),account=useRef(p.email),generation=useRef(0); + live.current=p.document;account.current=p.email; + function update(state:State){current.current=state;setRemote(state);} + function apply(state:State){state={...state,document:parseCanvasDocument(state.document)};synced.current=JSON.stringify(state.document);update(state);setPending(null);p.onLoad(state.document);} + async function run(action:()=>Promise){if(locked.current)return;locked.current=true;setBusy(true);try{await action()}catch(e){const text=e instanceof Error?e.message:'操作失败';setMessage(text);p.onFeedback(text)}finally{locked.current=false;setBusy(false)}} + useEffect(()=>{generation.current++;current.current=null;synced.current='';setRemote(null);setPending(null);setConnection(null);setCredential('');setItems([]);if(!p.email)return;const epoch=generation.current;void api('').then(data=>{if(epoch===generation.current)setItems(data.canvases)}).catch(()=>{});},[p.email]); + useEffect(()=>{if(!remote||!p.email)return;const id=remote.id,epoch=generation.current;let cancelled=false;const poll=async()=>{try{const state:State=await api('/'+id);if(cancelled||epoch!==generation.current||current.current?.id!==id||locked.current)return;if(state.revision!==current.current.revision){if(JSON.stringify(live.current)===synced.current){apply(state);setMessage('已同步 Agent 的画布更新')}else{setPending(state);setMessage('在线画布有更新,请选择要保留的版本')}}else update(state);const link=await api('/'+id+'/link');if(!cancelled&&epoch===generation.current)setConnection(link.link)}catch(e){if(!cancelled)setMessage(e instanceof Error?e.message:'同步失败')}};void poll();const timer=setInterval(()=>void poll(),5000);return()=>{cancelled=true;clearInterval(timer)};},[remote?.id,p.email]); + async function save():Promise{if(!account.current)throw Error('请先登录邮箱账户');if(pending)throw Error('请先处理在线版本冲突');const doc=live.current,serialized=JSON.stringify(doc),old=current.current;const state:State=old?serialized===synced.current?old:await api('/'+old.id,'PUT',{revision:old.revision,document:doc}):await api('','POST',{document:doc,name:'灵感画布 '+new Date().toLocaleDateString()});if(account.current!==p.email)throw Error('账户已切换,请重新连接');synced.current=serialized;update(state);setMessage('已保存在线画布');return state;} + useImperativeHandle(ref,()=>({queue:async target=>{setOpen(true);await run(async()=>{const state=await save();if(JSON.stringify(live.current)!==synced.current)throw Error('画布刚有修改,请重新派发任务');const task:Task=await api('/'+state.id+'/tasks','POST',{revision:state.revision,targetId:target});if(account.current!==p.email)return;update({...state,tasks:[task,...state.tasks]});setMessage('任务已创建,等待你的 coding agent 领取');p.onFeedback('任务已创建,等待 Agent')})}})); + async function connect(){const state=await save(),link=await api('/'+state.id+'/link','POST',{});if(account.current!==p.email)return;setConnection({expiresAt:link.expiresAt,lastSeenAt:null});setCredential(JSON.stringify({canvasUrl:location.origin+'/api/canvases/'+state.id,authorization:'Bearer '+link.token,documentation:location.origin+link.documentation,expiresAt:link.expiresAt},null,2));setMessage('连接凭据已生成,有效期 1 小时');} + const dirty=!!remote&&JSON.stringify(p.document)!==synced.current; + return
{open&&
你的 coding agent
{!p.email?登录后连接在线画布 ↗:<> +
{connection&&}
+ {!remote&&} + {connection&&{connection.lastSeenAt?'Agent 最近访问:'+new Date(connection.lastSeenAt).toLocaleTimeString():'等待 Agent 首次访问'} · 到期 {new Date(connection.expiresAt).toLocaleTimeString()}} + {credential&&<>