diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index a5eb2aa..5529d33 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -7,6 +7,8 @@ on: - 'apps/waterfall-wiki/**' - '.gitea/workflows/**' - 'scripts/*studio*' + - 'compose.creative-review.yml' + - 'scripts/integration.sh' concurrency: group: creative-workspace-${{ gitea.event.pull_request.number }} cancel-in-progress: true @@ -28,6 +30,7 @@ jobs: docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" . - name: Verify isolated PostgreSQL, S3 and browser integration env: + STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }} WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }} WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} GITEA_RUN_ID: ${{ gitea.run_id }} diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index c1e0eb4..e05abfa 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -11,6 +11,7 @@ COPY apps/studio/ . # Compare the two public parameter validators inside the isolated CI build. COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts +COPY apps/waterfall-wiki/src/lib/canvas-document.ts /waterfall-wiki/src/lib/canvas-document.ts RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke FROM node:24-bookworm-slim WORKDIR /app diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index 495a538..f78e547 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -1,91 +1,93 @@ -// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision. +// Isolated browser context against the designated deployed server only. const {chromium}=require('playwright-core'); const assert=require('node:assert/strict'); const fs=require('node:fs/promises'); (async()=>{ - const base=process.env.BASE_URL; - assert.ok(['http://8.211.154.134:18131','http://127.0.0.1:3024'].includes(base),'Use the designated server or the explicitly authorized local preview'); - const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true}:{channel:'chrome',headless:true}); + const base=process.env.BASE_URL||'http://8.211.154.134:18131'; + assert.equal(base,'http://8.211.154.134:18131'); + const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true}); try{ const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined}); - const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); - await page.goto(base+'/infinite_canvas'); - assert.equal(new URL(page.url()).pathname,'/infinite_canvas','Login required; authenticate without weakening protection'); - await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); - await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); - // A fresh context is used so this test cannot overwrite a human browser draft. + const page=await context.newPage(),errors=[];let choosers=0; + page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++); const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1'))); const node=id=>page.locator(`[data-node-id="${id}"]`); + const content=id=>node(id).locator('[data-node-content]'); + const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value}); + const composer=page.getByRole('region',{name:'节点编辑器'}); const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`); - async function drag(handle,dx,dy){const box=await handle.boundingBox();assert.ok(box);await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();await page.mouse.move(box.x+box.width/2+dx,box.y+box.height/2+dy,{steps:12});await page.mouse.up()} - for(const scale of [0,1]){ - await page.getByRole('button',{name:'适应画布',exact:true}).click(); - if(scale)await page.getByRole('button',{name:'放大',exact:true}).click(); - const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a); - for(const id of ['brief','reference']){ - const before=(await snapshot()).cards.find(c=>c.id===id); - await drag(node(id).locator(':scope > div').nth(1),45,25); - const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z); - } - } - // Double-click content enters editing without moving or creating nodes. - const nodeCount=(await snapshot()).cards.length; - const before=(await snapshot()).cards.find(c=>c.id==='brief'); - await node('brief').locator('textarea').dblclick({force:true}); - assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),null); - await node('brief').locator('textarea').fill('双击后直接编辑'); - assert.equal((await snapshot()).cards.length,nodeCount); - assert.equal((await snapshot()).cards.find(c=>c.id==='brief').x,before.x); - assert.equal((await snapshot()).cards.find(c=>c.id==='brief').y,before.y); + async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()} + await page.goto(base+'/infinite_canvas'); + await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); + await page.getByRole('button',{name:'适应画布',exact:true}).click(); + await content('brief').click({position:{x:20,y:20}}); + assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false'); + await page.waitForTimeout(700); + await content('brief').click({position:{x:20,y:20}}); + await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑'); + await waitField('brief','text','选择后再次点击进入编辑'); + await node('brief').getByRole('button',{name:'加粗',exact:true}).click(); + assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true); await page.keyboard.press('Escape'); - const chooser=page.waitForEvent('filechooser'); - await node('reference').getByRole('button',{name:/替换.*图片/}).dblclick(); - const imagePicker=await chooser; - await imagePicker.setFiles({name:'replacement.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jC1sAAAAASUVORK5CYII=','base64')}); - await page.waitForFunction(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(c=>c.id==='reference').image?.startsWith('data:image/png;base64,')); - assert.equal((await snapshot()).cards.length,nodeCount); + const before=(await snapshot()).cards.find(n=>n.id==='brief'); + const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a); + await drag(content('brief'),45,25); + const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z); + assert.equal(await node('brief').getAttribute('data-editing'),'false'); + + await content('reference').click();assert.equal(await composer.count(),0); + await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker'); + const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox(); + await page.getByRole('button',{name:'缩小',exact:true}).click(); + const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox(); + close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width!!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:'导出任务',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); + + await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click(); + await content('brief').click({position:{x:20,y:20}}); + await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click(); + await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click(); + const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script); + await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜'); + await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,''); + await composer.getByRole('button',{name:'画布参考',exact:true}).click(); + await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click(); + assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id)); + await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click(); + assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id)); await page.keyboard.press('Escape'); - assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),'');await node('brief').getByRole('button',{name:/^编辑 /}).click();await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点'); - const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y); - await drag(node('brief').locator('textarea'),20,10);const inEdit=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(inEdit.x,edited.x);assert.equal(inEdit.y,edited.y);await node('brief').getByRole('button',{name:/^完成编辑 /}).click();const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40); - assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320); - await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(c=>c.id==='brief').width,undefined); - await page.getByRole('button',{name:'重做',exact:true}).click();assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320); - // Each SVG endpoint must agree with the actual transformed node edge, after resize and pan. - async function wires(){const mismatch=await page.evaluate(()=>{ - return [...document.querySelectorAll('svg path')].filter(p=>p.getAttribute('d')?.startsWith('M ')).slice(0,1).some(p=>{const d=p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),world=p.closest('div').getBoundingClientRect();const matrix=p.getScreenCTM();const pt=new DOMPoint(d[0],d[1]).matrixTransform(matrix);const card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(pt.x-card.right)>3||Math.abs(pt.y-(card.top+card.height/2))>3}); - });assert.equal(mismatch,false)} - // The first link starts at brief; check it specifically. - await wires(); + await page.getByRole('button',{name:'适应画布',exact:true}).click(); + await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click(); + await page.getByRole('button',{name:'删除节点',exact:true}).click(); + assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference'))); + await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id)); + await page.keyboard.press('Escape'); + await content('brief').click({position:{x:20,y:20}}); + const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width; + await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30); + assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320)); + await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth); + await page.getByRole('button',{name:'重做',exact:true}).click(); await page.getByRole('button',{name:'移动画布',exact:true}).click(); - const viewport=page.locator('[class*="viewport"]');const box=await viewport.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+100,box.y+80,{steps:8});await page.mouse.up(); - await wires(); - const saved=await snapshot(),view=await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')); - await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.deepEqual(await snapshot(),saved);assert.equal(await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')),view); - // Zoom must preserve the world coordinate beneath the pointer. - const vp=await viewport.boundingBox();assert.equal(vp.x,0);assert.equal(vp.y,0);assert.equal(vp.width,1600);assert.equal(vp.height,1100); - const anchor={x:vp.x+vp.width*.65,y:vp.y+vp.height*.25}; - const worldAt=()=>page.locator('[class*=world]').evaluate((el,p)=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(p.x-m.e)/m.a,y:(p.y-m.f)/m.d}},anchor); - const point=await worldAt();await page.mouse.move(anchor.x,anchor.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control'); - await page.waitForFunction(old=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1.view')).zoom!==JSON.parse(old).zoom,view); - const pointAfter=await worldAt();close(point.x,pointAfter.x);close(point.y,pointAfter.y); - await page.getByRole('button',{name:'选择工具',exact:true}).click(); - await page.getByRole('button',{name:'添加笔记',exact:true}).click(); - assert.equal((await snapshot()).cards.length,saved.cards.length+1); - await page.getByRole('button',{name:/操作$/}).last().click(); - await page.getByRole('button',{name:'复制节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2); - await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1); - await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2); - await page.getByRole('button',{name:'重做',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1); - if(await page.getByRole('button',{name:'关闭画布操作',exact:true}).isVisible())await page.getByRole('button',{name:'关闭画布操作',exact:true}).click(); - // Return to the saved three-node document through undo. - await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).click(); - await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); - const dock=await page.getByRole('navigation',{name:'画布工具'}).boundingBox();assert.ok(dock.y>950);assert.ok(dock.x>500); - await page.getByRole('button',{name:'切换明暗主题'}).click();assert.equal(await page.locator('main').getAttribute('data-theme'),'light');await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light'); - if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-light.png'}); - await page.getByRole('button',{name:'切换明暗主题'}).click(); - if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-rebuild.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const mobile=await viewport.boundingBox();assert.equal(mobile.width,390);assert.equal(mobile.height,844);await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png'});} - assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan, reload, pointer zoom, CRUD and full-screen desktop/mobile'); + await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up(); + assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true); + const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}}); + const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control'); + await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z); + const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y); + const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved); + await page.getByRole('button',{name:'切换明暗主题',exact:true}).click();await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light'); + if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});} + await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844); + if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'}); + assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds'); }finally{await browser.close()} })().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/src/components/canvas/CanvasAgentPanel.tsx b/apps/studio/src/components/canvas/CanvasAgentPanel.tsx new file mode 100644 index 0000000..175c086 --- /dev/null +++ b/apps/studio/src/components/canvas/CanvasAgentPanel.tsx @@ -0,0 +1,38 @@ +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),credentialField=useRef(null); + 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 小时');} + async function copyCredential(){ + if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(credential);setMessage('凭据已复制');return}catch{}} + const field=credentialField.current;if(!field)return;field.focus();field.select(); + let copied=false;try{copied=document.execCommand('copy')}catch{} + setMessage(copied?'凭据已复制':'已选中凭据,请按 Ctrl+C 或 ⌘C 复制'); + } + 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&&<>