vmComputer.ts 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  1. import {WorkspaceGateway,type GatewayTarget} from './workspaceGateway.js'
  2. import {HttpError} from './errors.js'
  3. const schema=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
  4. const field={type:'string'}
  5. export const VM_COMPUTER_TOOLS=[
  6. {id:'computer_shell',name:'computer_shell',description:'在虚拟环境里执行命令。虚拟环境是服务端上的隔离 Linux 电脑,不会在服务器或已连接的电脑上执行。',inputSchema:schema({command:field,user:{type:'string',enum:['cua','root']}},['command'])},
  7. {id:'computer_read_file',name:'computer_read_file',description:'读取虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field},['path'])},
  8. {id:'computer_write_file',name:'computer_write_file',description:'写入虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field,content:field},['path','content'])},
  9. {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的真实桌面,返回截图和宽高。仅在需要看窗口或做点击输入时使用;命令和文件不要先截图。',inputSchema:schema({},[])},
  10. {id:'computer_action',name:'computer_action',description:'操作虚拟环境桌面。必须先查看桌面。kind 为 click、text、key 或 scroll,坐标用截图像素。',inputSchema:schema({kind:{enum:['click','drag','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},fromX:{type:'integer'},fromY:{type:'integer'},toX:{type:'integer'},toY:{type:'integer'},button:{enum:['left','right','middle']},count:{type:'integer'},text:field,key:field,modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},['kind'])},
  11. {id:'computer_export',name:'computer_export',description:'把虚拟环境里的文件作为附件回传到当前对话,最大 25 MiB。',inputSchema:schema({path:field,name:field},['path'])},
  12. ]
  13. export const VM_COMPUTER_RULES='操作 Bot 虚拟环境时,使用 computer_*;它是隔离的 Linux 电脑。命令和文件用 computer_shell、computer_read_file、computer_write_file,无需先截图;窗口操作先 computer_desktop_state 再 computer_action,页面变化后重新截图。用 computer_export 回传产物。不能用 Hermes 原生工具代替虚拟机操作,不混用其他环境的路径。仅在任务需要时调用,普通聊天不启动虚拟机。'
  14. export class VmToolSession {
  15. private gateway?: WorkspaceGateway
  16. private sessionId=''
  17. private opening?:Promise<void>
  18. constructor(private readonly target:GatewayTarget,private readonly profile:string,private readonly workId:string,private readonly authorize:()=>void,private readonly publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>){}
  19. private async open(){
  20. if(this.opening)return this.opening
  21. if(!this.gateway){
  22. const gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
  23. this.gateway=gateway
  24. this.opening=(async()=>{
  25. const opened=await gateway.rpc('session.create',{profile:this.profile,toolsOnly:true,title:'Yaoyao vm tools',source:'yaoyao_workspace',close_on_disconnect:true})
  26. this.authorize()
  27. if(this.gateway!==gateway)throw new Error('虚拟环境连接已关闭')
  28. this.sessionId=String(opened.session_id??'')
  29. if(!this.sessionId)throw new Error('虚拟环境没有打开')
  30. })()
  31. try{await this.opening}catch(error){if(this.gateway===gateway)this.close();throw error}finally{this.opening=undefined}
  32. }
  33. }
  34. async transfer(action:Record<string,unknown>){
  35. this.authorize()
  36. if(!this.target.runner?.fileTransfer)throw new HttpError(409,'执行节点尚不支持虚拟机分块传输,请更新 Runner','computer_transfer_upgrade_required')
  37. await this.open()
  38. return this.gateway!.rpc('computer.transfer',{session_id:this.sessionId,action})
  39. }
  40. async call(name:string,args:unknown,callId?:string){
  41. await this.open()
  42. const value=await this.gateway!.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{},...(callId?{id:callId}:{})})
  43. if(name==='computer_desktop_state'&&value&&typeof value==='object'&&(value as any)._multimodal){
  44. const url=String((value as any).content?.[0]?.image_url?.url??'')
  45. const data=url.includes(',')?url.split(',')[1]:''
  46. return {content:[{type:'image',mimeType:'image/png',data},{type:'text',text:JSON.stringify({width:(value as any).width,height:(value as any).height})}]}
  47. }
  48. return value
  49. }
  50. close(){const gateway=this.gateway;this.gateway=undefined;this.sessionId='';gateway?.close()}
  51. }