Bläddra i källkod

feat(bot): 多环境并列生效,去掉单选互斥

运行时按每个勾选环境放行,电脑面板同时显示本机和浏览器。勾选时不再清掉已有的本机权限,旧客户端仍读单值 computer。
Samien 1 månad sedan
förälder
incheckning
606f0df918

+ 16 - 6
src/client/components/workspace/LocalVmChatPanel.vue

@@ -16,6 +16,8 @@ const nativeSelected=computed(()=>native.value?.selected)
 const modern=computed(()=>!state.value?.fixedCapacity)
 const envsOverride=ref<Record<'vm'|'cloud'|'desktop'|'browser',boolean>>()
 const envs=computed(()=>{const base=agent.value?agentEnvs(agent.value):{vm:false,cloud:false,desktop:false,browser:false};return envsOverride.value?{...base,...envsOverride.value}:base})
+const showDesktop=computed(()=>modern.value?envs.value.desktop:nativeSelected.value==='local')
+const showBrowser=computed(()=>modern.value?envs.value.browser:nativeSelected.value==='browser')
 watch(()=>agent.value?agentEnvs(agent.value):undefined,base=>{
  if(!envsOverride.value||!base)return
  const keys=Object.keys(envsOverride.value) as (keyof typeof base)[]
@@ -36,6 +38,13 @@ const localVmSelected=computed(()=>canAllowHost.value&&!cloudSelected.value)
 const desktopHosts=computed(()=>native.value?.hosts??[])
 const activeEnvLine=computed(()=>{
  const parts:string[]=[]
+ if(modern.value){
+  if(envs.value.desktop)parts.push('本机控制')
+  if(envs.value.browser)parts.push('仅浏览器')
+  if(envs.value.cloud)parts.push('云端')
+  if(envs.value.vm)parts.push('本地虚拟机')
+  return parts.join(' · ')
+ }
  if(nativeSelected.value)parts.push(nativeSelected.value==='browser'?'仅浏览器':'本机控制')
  if(cloudSelected.value)parts.push('云端')
  if(showVm.value)parts.push('本地虚拟机')
@@ -205,8 +214,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
      <p v-if="native?.local.ready" class="hint">已连接 {{native.host?.name}},Bot 也可操作本机桌面。</p>
      <button v-else-if="native?.local.supported" :disabled="busy||!isAdmin" @click="authorizeHost(native?.host?.id??'')">授权本机桌面操作</button>
     </template>
-    <template v-if="nativeSelected">
-     <template v-if="nativeSelected==='local'">
+    <template v-if="showDesktop">
       <p>机器人默认在服务器({{native?.hosts?.some(h=>h.online)?'本机':'未连接'}})桌面上操作;已授权的其他 Mac 由机器人按名称点名操作,无需预先选择。</p>
       <div v-for="host in desktopHosts" :key="host.id" class="host-permission" :class="{offline:!host.online}">
        <div class="host-permission-head"><strong>{{host.id==='local'?'服务器本机':host.name||'桌面主机'}}</strong><small>{{host.online?'在线':'离线'}}</small></div>
@@ -216,10 +224,12 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
        <button v-else-if="host.online&&!host.local.fullAuthorized" :disabled="busy||!isAdmin" @click="authorizeHost(host.id,'full')">授权文件与命令</button>
        <p v-if="host.online&&host.local.ready&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台 Mac 的用户主目录内读写文件并执行命令;每台电脑单独授权,可随时撤销。</p>
       </div>
-     </template>
-     <p v-else-if="nativeSelected==='browser'">浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。</p>
-     <label v-else>浏览器资料<select :value="native?.browser.profile" :disabled="busy" @change="run(()=>apiRequest(`/api/app/agents/${selected}/browser-profile`,{method:'PUT',body:{profile:($event.target as HTMLSelectElement).value}}))"><option value="persistent">此机器人的浏览器 · 保存登录</option><option value="temporary">临时浏览器 · 退出桌面端后清除</option></select></label>
-     <button class="primary" :disabled="busy||!native?.online||(nativeSelected==='local'&&!native?.local.ready)" @click="openDesktop()">{{nativeSelected==='browser'?'打开浏览器':'接管本机'}}</button>
+      <button class="primary" :disabled="busy||!native?.online||!native?.local.ready" @click="openDesktop()">接管本机</button>
+    </template>
+    <template v-if="showBrowser">
+     <p>浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。</p>
+     <label>浏览器资料<select :value="native?.browser.profile" :disabled="busy" @change="run(()=>apiRequest(`/api/app/agents/${selected}/browser-profile`,{method:'PUT',body:{profile:($event.target as HTMLSelectElement).value}}))"><option value="persistent">此机器人的浏览器 · 保存登录</option><option value="temporary">临时浏览器 · 退出桌面端后清除</option></select></label>
+     <button class="primary" :disabled="busy||!native?.online" @click="openDesktop()">打开浏览器</button>
     </template>
     <template v-if="showCloud">
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>

+ 6 - 5
src/server/desktopEnvironments.ts

@@ -130,7 +130,9 @@ export class DesktopEnvironments {
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
  hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:info?.name??'',platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
  state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:info.name,platform:info.platform}:null,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
- private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.toolMode(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
+ /** Parallel envs allow each requested leg; legacy agents still use the single toolMode. */
+ private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){if(agent.envs){const wanted=this.modes(owner,agent);return mode==='browser'?wanted.browser:wanted.desktop}return this.toolMode(owner,agent)===mode}
+ private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||!this.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
  private invalidate(s:HostState){for(const p of s.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))s.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}s.pending.clear();s.frames.clear();for(const [key,m] of s.manual){s.paused.add(key);this.store.remove('_system','computer-control',m.id)}s.manual.clear()}
  close(){this.closed=true;for(const s of this.sessions.values())this.invalidate(s)}
  async bridge(ctx:Koa.Context){
@@ -180,10 +182,9 @@ export class DesktopEnvironments {
   if(a.envs){
    const tools=this.envTools(owner,a)
    if(!tools.view&&!tools.browser)return
-   const mode:Mode=tools.view?'local':'browser'
-   const s=tools.view?this.readySession(owner)??this.sessionFor(a):this.sessionFor(a)
-   if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
-   this.context(s,owner,a,mode).check();await requireTeamToolBridge(target,a.profile);return
+   if(tools.view){const s=this.readySession(owner)??this.sessionFor(a);if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline');this.context(s,owner,a,'local').check()}
+   if(tools.browser){const s=this.sessionFor(a);if(!s||!this.hostOnline(s))throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline');this.context(s,owner,a,'browser').check()}
+   await requireTeamToolBridge(target,a.profile);return
   }
   const mode=this.toolMode(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(this.sessionFor(a),owner,a,mode).check();await requireTeamToolBridge(target,a.profile)
  }

+ 2 - 2
src/server/grokCloud.ts

@@ -200,11 +200,11 @@ export class GrokCloud {
       this.beforeSelection?.(owner,agent.id);this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
       if(legacyBody&&legacyBody.vmExecution==='profile'&&legacyBody.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
       const vmExecution=targetComputer==='vm'?(next?(agent.vmExecution??'worker'):(legacyBody!.vmExecution??(legacyBody!.allowHostEnvironment===false?'worker':agent.vmExecution)??'worker')):'worker'
-      const allowHostEnvironment=next?false:((legacyBody!.computer==='vm'&&vmExecution==='profile')||(['vm','cloud'].includes(legacyBody!.computer)&&(legacyBody!.allowHostEnvironment??(legacyBody!.vmExecution==='worker'?false:agent.allowHostEnvironment)??false)))
+      const allowHostEnvironment=next?((next.vm||next.cloud)&&agent.allowHostEnvironment===true):((legacyBody!.computer==='vm'&&vmExecution==='profile')||(['vm','cloud'].includes(legacyBody!.computer)&&(legacyBody!.allowHostEnvironment??(legacyBody!.vmExecution==='worker'?false:agent.allowHostEnvironment)??false)))
       if(legacyBody&&legacyBody.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true,vmExecution})
       const enteringCloud=(next?next.cloud:legacyBody!.computer==='cloud')&&!current.cloud
       if(enteringCloud)await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
-      this.store.atomic(()=>{const execution=targetComputer==='vm'?'computer':targetComputer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{...(next?{envs:next}:{computer:legacyBody!.computer}),...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{}),execution,allowHostEnvironment,vmExecution});const updated=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,updated);this.store.put(owner,'agent',agent.id,updated)})
+      this.store.atomic(()=>{const execution=next?(next.vm?'computer':'profile'):targetComputer==='vm'?'computer':targetComputer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{...(next?{envs:next}:{computer:legacyBody!.computer}),...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{}),execution,allowHostEnvironment,vmExecution});const updated=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,updated);this.store.put(owner,'agent',agent.id,updated)})
       if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })

+ 9 - 0
tests/server/desktopEnvironments.test.ts

@@ -135,6 +135,15 @@ it('routes parallel desktop tools to the host named in the call',async()=>{
   expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
  }finally{clearInterval(pollerB)}
 })
+it('keeps desktop and browser tools open together when both environments are on',async()=>{
+ agent=store.updateAgent('owner',agent.id,{envs:{vm:true,desktop:true,browser:true,cloud:false}})
+ drive()
+ expect(service.selected('owner',agent)).toBe('local')
+ expect(service.toolMode('owner',agent)).toBe('local')
+ await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).resolves.toEqual({ok:true})
+ const view=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
+ expect(view.content[0].type).toBe('image')
+})
 it('degrades envs desktop tools when no host is ready or online',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'off',envs:{desktop:true,browser:true}})
  expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:false})