control.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. import {randomUUID,createHash} from 'node:crypto'
  2. import {z} from 'zod'
  3. import {HttpError} from '../../server/errors.js'
  4. import {CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
  5. import type {ComputerLease} from '../computers/pool.js'
  6. import {ComputerPublicProxy} from '../network/computerProxy.js'
  7. import type {ComputerRuntime,ComputerGateway,ComputerTarget} from './gateway.js'
  8. import type {ComputerControlStatus,ComputerFrame} from '../../shared/computerControl.js'
  9. const actionSchema=z.discriminatedUnion('kind',[
  10. z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
  11. z.object({kind:z.literal('drag'),fromX:z.number().int().nonnegative(),fromY:z.number().int().nonnegative(),toX:z.number().int().nonnegative(),toY:z.number().int().nonnegative()}).strict(),
  12. z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
  13. z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
  14. z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
  15. ])
  16. interface Manual {
  17. id:string;meta:ComputerTarget;profile:string;mode:ComputerControlStatus['mode'];valid:boolean
  18. gateway?:ComputerGateway;lease?:ComputerLease;spec?:ComputerSpecification;proxy?:ComputerPublicProxy
  19. pending?:Promise<void>;error?:string;check():Promise<void>;checking:boolean
  20. stopping?:Promise<void>;actions:Map<string,{fingerprint:string;result:Promise<unknown>}>
  21. }
  22. export class ComputerControls {
  23. private manual=new Map<string,Manual>()
  24. private frames=new Map<string,ComputerFrame[]>()
  25. private captures=new Map<string,Promise<ComputerFrame>>()
  26. private timer:ReturnType<typeof setInterval>
  27. constructor(readonly runtime:ComputerRuntime){
  28. this.timer=setInterval(()=>{for(const state of this.manual.values()){
  29. if(state.checking)continue;state.checking=true
  30. void state.check().then(()=>{if(state.lease&&state.mode==='human')this.runtime.pool.renew(state.lease)}).catch(()=>this.stop(state)).finally(()=>{state.checking=false}).catch(()=>{})
  31. }},5000);this.timer.unref()
  32. }
  33. private guard(state:Manual){if(!state.valid||this.manual.get(state.meta.environmentId)!==state)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')}
  34. private state(meta:ComputerTarget){const state=this.manual.get(meta.environmentId);if(state&&state.meta.ownerKey!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch');return state}
  35. async status(meta:ComputerTarget):Promise<ComputerControlStatus>{
  36. const state=this.state(meta)
  37. if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
  38. const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
  39. return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
  40. }
  41. async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
  42. this.runtime.assertTarget(meta);authorize()
  43. const spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)
  44. if(!spec)throw new HttpError(409,'电脑尚未启动','computer_not_running')
  45. const status=await this.status(meta),cached=this.frames.get(meta.environmentId)?.at(-1)
  46. if(cached&&cached.generation===status.generation&&Date.now()-cached.capturedAt<700)return cached
  47. const existing=this.captures.get(meta.environmentId);if(existing)return existing.then(frame=>{authorize();return frame})
  48. const pending=this.runtime.provider.capture(spec,authorize).then(frame=>{
  49. authorize();const result:ComputerFrame={...frame,id:randomUUID(),generation:status.generation??0}
  50. const history=(this.frames.get(meta.environmentId)??[]).filter(frame=>Date.now()-frame.capturedAt<10000).slice(-4)
  51. this.frames.set(meta.environmentId,[...history,result]);return result
  52. }).finally(()=>this.captures.delete(meta.environmentId))
  53. this.captures.set(meta.environmentId,pending);return pending
  54. }
  55. async take(meta:ComputerTarget,profile:string,id:string,check:()=>Promise<void>):Promise<ComputerControlStatus>{
  56. this.runtime.assertTarget(meta);await check()
  57. const old=this.state(meta)
  58. if(old){if(old.id===id)return this.status(meta);throw new HttpError(409,'电脑已由另一控制会话接管','computer_control_busy')}
  59. const gateway=this.runtime.gateways.get(meta.environmentId)
  60. const state:Manual={id,meta,profile,mode:'pausing',valid:true,gateway:gateway?.active?gateway:undefined,check,checking:false,actions:new Map()}
  61. this.manual.set(meta.environmentId,state)
  62. state.pending=(async()=>{
  63. if(state.gateway){state.lease=await state.gateway.takeControl(id,()=>this.guard(state));state.spec=state.gateway.specification}
  64. else{
  65. const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
  66. state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId,cwd:resolved.cwd,network:this.runtime.config.network??'none'}
  67. await this.runtime.pool.configure(state.spec,()=>this.guard(state))
  68. state.lease=await this.runtime.pool.acquire(state.spec,`human:${id}`,()=>this.guard(state))
  69. if(this.runtime.config.network==='public-proxy')state.proxy=await ComputerPublicProxy.start(this.runtime.provider,state.spec,this.runtime.proxyScript,()=>this.runtime.pool.authorize(state.lease!),check,()=>{void this.stop(state)})
  70. }
  71. await check();this.guard(state);state.mode='human'
  72. })().catch(async()=>{if(!state.valid)return;state.mode='error';state.error='接管未完成,请等待当前操作结束后重试';await this.stop(state).catch(()=>{})})
  73. return this.status(meta)
  74. }
  75. async input(meta:ComputerTarget,id:string,requestId:string,generation:number,frameId:string,value:unknown):Promise<unknown>{
  76. const state=this.state(meta)
  77. if(!state||state.id!==id||state.mode!=='human'||!state.lease||!state.spec)throw new HttpError(403,'需要当前电脑的控制权','computer_control_required')
  78. this.guard(state);await state.check()
  79. if(state.lease.generation!==generation)throw new HttpError(409,'控制版本已变化','computer_control_stale')
  80. const frame=this.frames.get(meta.environmentId)?.find(frame=>frame.id===frameId&&frame.generation===generation&&Date.now()-frame.capturedAt<10000)
  81. if(!frame)throw new HttpError(409,'画面已过期,请刷新后操作','computer_frame_stale')
  82. const action=actionSchema.parse(value),fingerprint=createHash('sha256').update(JSON.stringify({action,generation,frameId})).digest('hex')
  83. const old=state.actions.get(requestId)
  84. if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'操作编号与内容不一致','idempotency_conflict');return old.result}
  85. if(state.actions.size>=1000)throw new HttpError(429,'本次接管操作已达上限,请重新接管','computer_control_limit')
  86. const common={scope:'desktop',session:`yaoyao-control-${state.id}`}
  87. let tool:string,args:Record<string,unknown>,argv:string[]|undefined
  88. if(action.kind==='click'){
  89. if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
  90. tool='click';args={...common,x:action.x,y:action.y,button:action.button,count:action.count}
  91. }else if(action.kind==='drag'){
  92. if(action.fromX>=frame.width||action.toX>=frame.width||action.fromY>=frame.height||action.toY>=frame.height)throw new HttpError(400,'拖动位置超出画面','computer_point_invalid')
  93. tool='drag';args={}
  94. argv=['env','DISPLAY=:1','xdotool','mousemove','--sync',String(action.fromX),String(action.fromY),'mousedown','1','mousemove','--sync',String(action.toX),String(action.toY),'mouseup','1']
  95. }else if(action.kind==='text'){tool='type_text';args={...common,text:action.text}}
  96. else if(action.kind==='key'){
  97. const key=({Return:'enter',BackSpace:'backspace',Escape:'esc'} as Record<string,string>)[action.key]??action.key.toLowerCase()
  98. tool=action.modifiers.length?'hotkey':'press_key';args=action.modifiers.length?{...common,keys:[...action.modifiers,key]}:{...common,key}
  99. }else {tool='scroll';args={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
  100. const result=this.runtime.pool.use(state.lease,async context=>{
  101. this.guard(state);await state.check()
  102. const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
  103. this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
  104. if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
  105. return {ok:true,result:response.stdout}
  106. })
  107. state.actions.set(requestId,{fingerprint,result});return result
  108. }
  109. async giveBack(meta:ComputerTarget,id:string,notes:string){
  110. const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
  111. await state.pending;this.guard(state);await state.check()
  112. if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
  113. else if(this.runtime.retainDesktops&&state.lease){
  114. await state.proxy?.close();await this.runtime.pool.release(state.lease,true)
  115. state.valid=false;this.manual.delete(meta.environmentId)
  116. }else await this.stop(state)
  117. return {ok:true}
  118. }
  119. private async stop(state:Manual){
  120. if(state.stopping)return state.stopping
  121. state.valid=false
  122. state.stopping=(async()=>{
  123. await state.proxy?.close()
  124. if(state.gateway)await state.gateway.stopControl()
  125. else if(state.lease)await this.runtime.pool.release(state.lease).catch(error=>{if(error.code!=='computer_lease_stale')throw error})
  126. if(this.manual.get(state.meta.environmentId)===state)this.manual.delete(state.meta.environmentId)
  127. })().catch(error=>{state.mode='error';state.error='电脑停止状态待确认';state.stopping=undefined;throw error})
  128. return state.stopping
  129. }
  130. async close(){clearInterval(this.timer);await Promise.allSettled([...this.manual.values()].map(state=>this.stop(state)))}
  131. }