profileSession.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import {setTimeout as delay} from 'node:timers/promises'
  2. import {HttpError} from '../../server/errors.js'
  3. import {WorkspaceGateway,type GatewayFrame,type GatewayTarget} from '../../server/workspaceGateway.js'
  4. import {createWorkspaceToolLease,requireTeamToolBridge,type WorkspaceToolLease} from '../../server/workspaceToolLease.js'
  5. import type {WorkerTool} from './process.js'
  6. /** A normal Hermes Profile session, with the same native tool bridge as cloud computers. */
  7. export class ProfileComputerSession {
  8. private readonly gateway:WorkspaceGateway
  9. private toolLease?:WorkspaceToolLease
  10. private runtimeId=''
  11. storedId=''
  12. private closed=false
  13. private readonly controller=new AbortController()
  14. onEvent:(frame:GatewayFrame)=>void=()=>{}
  15. onDisconnect:()=>void=()=>{}
  16. onStoredId:(id:string)=>void=()=>{}
  17. constructor(readonly target:GatewayTarget,readonly profile:string,readonly workId:string,
  18. readonly authorize:()=>void,readonly catalog:()=>WorkerTool[],
  19. readonly call:(name:string,args:unknown,id:string)=>Promise<unknown>,
  20. readonly computerPolicy?: {mode:'isolated'|'profile';hostAccess:boolean},readonly workspaceMemory:()=>boolean=()=>false){
  21. this.gateway=new WorkspaceGateway(target)
  22. this.gateway.onDisconnect=()=>{if(!this.closed)this.onDisconnect()}
  23. this.gateway.onEvent=frame=>{
  24. if(this.closed||!this.runtimeId||frame.session_id!==this.runtimeId)return
  25. if(frame.type==='session.info'){
  26. const id=frame.payload?.stored_session_id??frame.payload?.session_key
  27. if(typeof id==='string'&&id){this.storedId=id;this.onStoredId(id)}
  28. }
  29. this.onEvent(frame)
  30. }
  31. }
  32. async open(storedId:string|undefined,params:Record<string,unknown>){
  33. if(this.computerPolicy){
  34. const response=await this.target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile:this.profile}),cache:'reload'})
  35. let capability:any;try{capability=JSON.parse(response.body.toString())}catch{}
  36. if(response.status!==200||capability?.ready!==true||capability?.computer_runtime_version!==2)
  37. throw new HttpError(409,'请更新夭夭工具桥并重启 Hermes Dashboard 服务,当前服务尚未加载托管虚拟机会话能力。','computer_bridge_upgrade_required')
  38. }
  39. await this.gateway.connect()
  40. const {session_id:_virtual,recoverOnly:_recover,...options}=params
  41. const opened=await this.gateway.rpc(storedId?'session.resume':'session.create',{
  42. ...options,profile:this.profile,close_on_disconnect:false,...(storedId?{session_id:storedId}:{}),
  43. })
  44. this.runtimeId=String(opened.session_id??'')
  45. this.storedId=String(opened.stored_session_id??opened.session_key??storedId??'')
  46. if(!this.runtimeId||!this.storedId)throw new HttpError(502,'本机 Profile 会话身份不完整','computer_profile_session_invalid')
  47. this.onStoredId(this.storedId)
  48. return opened
  49. }
  50. async bind(){
  51. this.authorize()
  52. if(!this.toolLease){
  53. await requireTeamToolBridge(this.target,this.profile)
  54. this.toolLease=await createWorkspaceToolLease({target:this.target,profile:this.profile,workId:this.workId,
  55. session:()=>({runtimeId:this.runtimeId,storedId:this.storedId}),signal:this.controller.signal,computerPolicy:this.computerPolicy,workspaceMemory:this.workspaceMemory(),
  56. assertActive:()=>{this.controller.signal.throwIfAborted();this.authorize()},
  57. catalog:()=>this.catalog().map(tool=>({...tool,id:tool.id??tool.name})),
  58. call:async(name,args,id)=>{
  59. const value=await this.call(name,args,id!)
  60. // Hermes Worker uses OpenAI image blocks; the Profile bridge speaks MCP.
  61. if(value&&typeof value==='object'&&(value as any)._multimodal){
  62. const result=value as any
  63. return {content:[{type:'text',text:result.text_summary??''},...result.content.map((item:any)=>{
  64. const match=/^data:(image\/[a-z]+);base64,(.+)$/.exec(item.image_url?.url??'')
  65. if(!match)throw new Error('虚拟机截图格式无效')
  66. return {type:'image',mimeType:match[1],data:match[2]}
  67. })]}
  68. }
  69. return value
  70. },onFailure:()=>{void this.stop().finally(()=>this.onDisconnect()).catch(()=>{})},
  71. })
  72. }
  73. await this.toolLease.bind()
  74. }
  75. rpc(method:string,params:Record<string,unknown>={}){
  76. const {session_id:_virtual,workMarker:_marker,...options}=params
  77. // session.active_list is manager-scoped: Hermes' contract is extra=forbid and rejects session_id.
  78. if(method==='session.active_list')return this.gateway.rpc(method,{...options,profile:this.profile})
  79. return this.gateway.rpc(method,{...options,session_id:this.runtimeId})
  80. }
  81. async file(action:'read'|'write',path:string,data?:string){
  82. this.authorize()
  83. if(!this.toolLease?.profileRequest)throw new HttpError(403,'Hermes 文件授权尚未建立','computer_file_forbidden')
  84. return this.toolLease.profileRequest('/computer-file',{action,path,...(data!==undefined?{data}:{})})
  85. }
  86. async stop(){
  87. if(!this.runtimeId||this.closed)return
  88. await this.rpc('session.interrupt')
  89. // An interrupt acknowledgement precedes actual termination of native tools.
  90. const deadline=Date.now()+20000
  91. for(;;){
  92. const state=await this.rpc('session.active_list')
  93. const session=state.sessions?.find((item:{id:string})=>item.id===this.runtimeId)
  94. if(session?.status==='idle')return
  95. if(Date.now()>=deadline)throw new HttpError(409,'本机任务尚未停止,暂不能交出电脑控制权','computer_profile_stopping')
  96. await delay(100,undefined,{signal:this.controller.signal})
  97. }
  98. }
  99. async close(){
  100. if(this.closed)return
  101. this.closed=true;this.controller.abort()
  102. await this.toolLease?.dispose()
  103. this.gateway.close()
  104. }
  105. }