Procházet zdrojové kódy

feat(envs): 电脑面板并列勾选与分后端接管查看

envs 机器人(勾选过环境后)的面板改为多选列表:本地虚拟机/云端/
本机控制/仅浏览器并列开关,全部取消即不启用;选项区按启用环境
并列展示各自控制块。/computer 接管通道新增 backend 查询参数
(desktop|cloud|vm)三段路由透传,ComputerPanel 支持指定后端,
云端/虚拟机按钮直达对应查看器。旧 bot 保留原单选界面,首次勾选
即迁移。
Samien před 1 měsícem
rodič
revize
5fe21063cc

+ 4 - 3
src/client/components/workspace/ComputerPanel.vue

@@ -7,7 +7,7 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean;backend?:'desktop'|'cloud'|'vm'}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
@@ -20,10 +20,11 @@ const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.con
 const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
 const controllable=computed(()=>canSend.value&&!busy.value)
 const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
-async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
+const query=()=>props.backend?'?backend='+encodeURIComponent(props.backend):''
+async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
 async function refresh(){
   if(polling||busy.value||closed||!selected.value||document.hidden)return
-  polling=true;const current=++generation,base=endpoint()
+  polling=true;const current=++generation,base=endpoint()+query()
   try{
     const status=await apiRequest<ComputerControlStatus>(base)
     if(current!==generation||closed)return

+ 36 - 18
src/client/components/workspace/LocalVmChatPanel.vue

@@ -4,17 +4,25 @@ import {apiRequest} from '@/api/client'
 import AppIcon from '@/components/common/AppIcon.vue'
 import type {DesktopEnvironmentState,BotBrowserState} from '@shared/desktopEnvironment'
 import GrokAuthPanel from './GrokAuthPanel.vue'
-import {supportsHostEnvironment,type WorkspaceAgent} from '@shared/workspace'
+import {supportsHostEnvironment,agentEnvs,type WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
 import {vmIdleStopLabel,type LocalVmInstance,type LocalVmAction} from '@shared/localVm'
 const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean;embedded?:boolean}>()
-const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
+const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent,backend?:'desktop'|'cloud'|'vm'];workspace:[agent:WorkspaceAgent]}>()
 const selected=ref(''),state=ref<LocalVmInstance&{enabled:boolean;controlMode?:string}>(),frame=ref<ComputerFrame>(),error=ref(''),busy=ref(false),loading=ref(true)
 const agent=computed(()=>props.agents.find(a=>a.id===selected.value))
 const native=ref<DesktopEnvironmentState>()
 const nativeSelected=computed(()=>native.value?.selected)
+const modern=computed(()=>!!agent.value?.envs)
+const envs=computed(()=>agent.value?agentEnvs(agent.value):{vm:false,cloud:false,desktop:false,browser:false})
+function toggleEnv(key:'vm'|'cloud'|'desktop'|'browser',enabled:boolean){
+ if(computerChoiceDisabled.value)return
+ void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{envs:{...envs.value,[key]:enabled}}}))
+}
 const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
-const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&!nativeSelected.value&&agent.value.execution!=='computer'&&cloud.value?.configured))
+const cloudSelected=computed(()=>modern.value?envs.value.cloud:agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&!nativeSelected.value&&agent.value.execution!=='computer'&&!!cloud.value?.configured))
+const showCloud=computed(()=>cloudSelected.value&&(!nativeSelected.value||modern.value))
+const showVm=computed(()=>modern.value?(envs.value.vm&&!!state.value?.enabled):(!!state.value?.enabled&&!nativeSelected.value&&!cloudSelected.value))
 const computerChoice=computed(()=>state.value?.fixedCapacity&&agent.value?.execution==='computer'?(state.value.desktopId??'off'):agent.value?.computer??(state.value?.enabled?'vm':'off'))
 const canAllowHost=computed(()=>!!agent.value&&supportsHostEnvironment(agent.value))
 const localVmSelected=computed(()=>canAllowHost.value&&!cloudSelected.value)
@@ -59,17 +67,20 @@ async function refresh(){
    if(nativeSelected.value==='browser'){const b=await apiRequest<BotBrowserState>(`/api/app/agents/${selected.value}/browser`);ready=b.open}
    state.value={enabled:true,container:ready?'running':'stopped',ready,image:true,mode:'shared',maxInstances:1,inUse:false}
    if(ready){const f=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=f}else frame.value=undefined
-   error.value='';return
+   error.value=''
   }
   const cloudState=await apiRequest<typeof cloud.value>(`/api/app/agents/${selected.value}/cloud-computer`);if(version!==revision||closed)return;cloud.value=cloudState
-  if(cloudSelected.value){
+  if(showCloud.value){
    state.value={enabled:true,container:cloudState?.running?'running':'stopped',ready:!!cloudState?.connected&&!!cloudState?.running,image:!!cloudState?.configured,mode:'shared',maxInstances:1,inUse:cloudState?.mode==='agent'||cloudState?.mode==='human',controlMode:cloudState?.mode}
-   if(state.value.ready){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}else frame.value=undefined
-   error.value='';return
+   if(state.value.ready&&!nativeSelected.value){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}else if(!nativeSelected.value)frame.value=undefined
+   error.value=''
+  }
+  const wantVm=modern.value?envs.value.vm:(!nativeSelected.value&&!cloudSelected.value)
+  if(wantVm){
+   const result=await apiRequest<typeof state.value>(base());if(version!==revision||closed)return;state.value=result
+   if(result?.container==='running'&&result.ready&&!nativeSelected.value&&!showCloud.value){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}
+   else if(!nativeSelected.value&&!showCloud.value)frame.value=undefined
   }
-  const result=await apiRequest<typeof state.value>(base());if(version!==revision||closed)return;state.value=result
-  if(result?.container==='running'&&result.ready){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}
-  else frame.value=undefined
   error.value=''
  }catch(e){if(version===revision)error.value=e instanceof Error?e.message:'无法读取电脑画面'}finally{if(version===revision)loading.value=false}
 }
@@ -89,7 +100,7 @@ function chooseVmExecution(value:string){
  if(computerChoiceDisabled.value||!localVmSelected.value)return
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:'vm',vmExecution:value,allowHostEnvironment:value==='profile'}}))
 }
-async function openDesktop(){if(!agent.value)return;if(cloudSelected.value){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}emit('desktop',agent.value)}
+async function openDesktop(backend?:'desktop'|'cloud'|'vm'){if(!agent.value)return;if(backend==='cloud'||(!backend&&cloudSelected.value&&!nativeSelected.value)){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}emit('desktop',agent.value,backend)}
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
  if(value==='remove'&&!confirm('移除这台虚拟机实例以切换镜像?工作文件和浏览器资料会保留。'))return
@@ -109,7 +120,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
    <p v-if="!agent">当前聊天没有可用的机器人。</p>
    <template v-else>
     <div class="screen-caption"><span>{{agent.name}}的电脑</span><small v-if="state?.enabled">{{nativeSelected?(native?.host?.name??'桌面端离线'):cloudSelected?'Grok Bot 云端':'本地虚拟机'}}</small></div>
-    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openDesktop">
+    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openDesktop()">
      <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${agent.name}的电脑画面`">
      <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':nativeSelected?(nativeSelected==='browser'?'点击打开机器人的独立浏览器':native?.local.ready?'正在读取本机画面…':'请完成本机控制授权'):!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
@@ -117,7 +128,14 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
     <div class="computer-select">
      <span>此机器人使用的电脑</span>
-     <div ref="computerTabs" class="computer-tabs" role="tablist" aria-label="此机器人使用的电脑" @keydown="moveComputerTab">
+     <div v-if="modern" class="env-checks" role="group" aria-label="此机器人使用的电脑(可多选)">
+      <label class="env-check"><input type="checkbox" :checked="envs.vm" :disabled="computerChoiceDisabled" @change="toggleEnv('vm',($event.target as HTMLInputElement).checked)"><span>本地虚拟机<small>在隔离的本地桌面中工作;执行节点离线时自动改用本机会话</small></span></label>
+      <label class="env-check"><input type="checkbox" :checked="envs.cloud" :disabled="computerChoiceDisabled" @change="toggleEnv('cloud',($event.target as HTMLInputElement).checked)"><span>云端 · Grok Bot<small>共享云端电脑,持续保存工作</small></span></label>
+      <label class="env-check"><input type="checkbox" :checked="envs.desktop" :disabled="computerChoiceDisabled" @change="toggleEnv('desktop',($event.target as HTMLInputElement).checked)"><span>本机控制<small>{{native?.hosts?.length?`已连接 ${native.hosts.filter((h:any)=>h.online).length} 台 Mac,工具可按名称选择`:'暂无已连接的 Mac'}}</small></span></label>
+      <label class="env-check"><input type="checkbox" :checked="envs.browser" :disabled="computerChoiceDisabled" @change="toggleEnv('browser',($event.target as HTMLInputElement).checked)"><span>仅浏览器<small>在连接的 Mac 上使用机器人独立浏览器</small></span></label>
+      <p class="hint">勾选的环境并列可用;全部取消即不使用任何电脑环境。</p>
+     </div>
+     <div v-else ref="computerTabs" class="computer-tabs" role="tablist" aria-label="此机器人使用的电脑" @keydown="moveComputerTab">
       <button v-for="option in computerChoices" :id="`computer-tab-${selected}-${option.value}`" :key="option.value" type="button" role="tab" :aria-selected="computerChoice===option.value" :aria-controls="`computer-options-${selected}`" :tabindex="computerChoice===option.value?0:-1" :aria-label="option.label" :title="option.description" :disabled="computerChoiceDisabled||option.disabled" @click="selectComputer(option.value)"><span><AppIcon :name="computerChoice===option.value?'check':option.icon" :size="15"/>{{option.label}}</span><small>{{option.description}}</small></button>
      </div>
     </div>
@@ -152,15 +170,15 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
       <p v-if="native?.local.ready&&!native?.local.fullAuthorized" class="hint">授权后,机器人可以在这台 Mac 的用户主目录内读写文件并执行命令;每台电脑单独授权,可随时撤销。</p>
      </template>
      <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||(nativeSelected==='local'&&!native?.local.ready)" @click="openDesktop()">{{nativeSelected==='browser'?'打开浏览器':'接管本机'}}</button>
     </template>
-    <template v-else-if="cloudSelected">
+    <template v-if="showCloud">
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
-     <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
+     <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop('cloud')">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
      <button @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
      <p v-if="!isAdmin&&!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
     </template>
-    <template v-else-if="state?.enabled">
+    <template v-if="showVm&&state">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
      <template v-if="!state.fixedCapacity&&state.images?.length&&!agent.temporaryGoalId">
       <label>虚拟机镜像<select :value="state.imageKey??''" :disabled="busy||state.inUse||state.container!=='missing'" @change="run(()=>apiRequest(base()+'/image',{method:'PUT',body:{imageKey:($event.target as HTMLSelectElement).value}}))"><option v-if="!state.imageKey" value="" disabled>{{state.image?'当前保留的镜像':'请选择已准备的镜像'}}</option><option v-for="image in state.images" :key="image.key" :value="image.key" :disabled="!image.ready">{{image.name}}{{image.ready?'':' · 未准备'}}</option></select></label>
@@ -170,7 +188,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
      <button v-else-if="!state.fixedCapacity&&state.container==='missing'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('create')">创建 {{agent.name}} 的虚拟机</button>
      <button v-else-if="!state.fixedCapacity&&state.container==='stopped'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('start')">启动虚拟机</button>
      <template v-else-if="state.container==='running'">
-      <button class="primary" :disabled="!state.ready||busy" @click="openDesktop">打开桌面</button>
+      <button class="primary" :disabled="!state.ready||busy" @click="openDesktop(modern?'vm':undefined)">打开桌面</button>
       <button v-if="state.mode==='per-bot'" :disabled="busy" @click="emit('workspace',agent)"><AppIcon name="panel"/>打开双桌面</button>
       <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'机器人正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>

+ 2 - 2
src/client/components/workspace/WorkspaceBotPanel.vue

@@ -6,7 +6,7 @@ import WorkspaceInspectorPanel from './WorkspaceInspectorPanel.vue'
 import WorkspaceRoutinesPanel from './WorkspaceRoutinesPanel.vue'
 import type {WorkspaceAgent} from '@shared/workspace'
 const props=defineProps<{agents:WorkspaceAgent[];conversationId:string;taskId?:string;mode:'computer'|'inspector';isAdmin:boolean;active?:boolean}>()
-const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
+const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent,backend?:'desktop'|'cloud'|'vm'];workspace:[agent:WorkspaceAgent]}>()
 const view=ref<'computer'|'routines'>('computer')
 watch(()=>props.conversationId,()=>{let saved;try{saved=localStorage.getItem('yaoyao-bot-panel:'+props.conversationId)}catch{}view.value=saved==='routines'?'routines':'computer'},{immediate:true})
 watch(view,value=>{try{localStorage.setItem('yaoyao-bot-panel:'+props.conversationId,value)}catch{}})
@@ -15,7 +15,7 @@ watch(view,value=>{try{localStorage.setItem('yaoyao-bot-panel:'+props.conversati
  <aside class="bot-panel" aria-label="机器人右侧栏">
   <header v-if="mode==='computer'"><div class="tabs" role="group" aria-label="电脑与定时任务"><button :aria-pressed="view==='computer'" @click="view='computer'"><AppIcon name="monitor" :size="15"/>电脑</button><button :aria-pressed="view==='routines'" @click="view='routines'"><AppIcon name="calendar" :size="15"/>定时任务</button></div><button class="close" aria-label="关闭机器人侧栏" @click="emit('close')"><AppIcon name="close"/></button></header>
   <WorkspaceInspectorPanel v-if="mode==='inspector'" show-close :conversation-id="conversationId" :task-id="taskId" @close="emit('close')"/>
-  <LocalVmChatPanel v-else-if="view==='computer'" embedded :agents="agents" :is-admin="isAdmin" :active="active" @changed="emit('changed')" @settings="emit('settings')" @desktop="emit('desktop',$event)" @workspace="emit('workspace',$event)"/>
+  <LocalVmChatPanel v-else-if="view==='computer'" embedded :agents="agents" :is-admin="isAdmin" :active="active" @changed="emit('changed')" @settings="emit('settings')" @desktop="(agent,backend)=>emit('desktop',agent,backend)" @workspace="emit('workspace',$event)"/>
   <WorkspaceRoutinesPanel v-else-if="agents[0]" :agent-id="agents[0].id"/>
  </aside>
 </template>

+ 4 - 4
src/client/views/ConversationsView.vue

@@ -237,13 +237,13 @@ let cursor = 0,
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
-const computerOpen=ref(false),dockView=ref<'computer'|'inspector'>(),twoDesktops=ref(false),desktopAgent=ref<Agent>()
+const computerOpen=ref(false),dockView=ref<'computer'|'inspector'>(),twoDesktops=ref(false),desktopAgent=ref<Agent>(),desktopBackend=ref<'desktop'|'cloud'|'vm'>()
 const creatingTask=ref(false)
 const CREATE_TASK_VALUE='__create_task__'
 function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
-async function openComputer(agent:Agent){
+async function openComputer(agent:Agent,backend?:'desktop'|'cloud'|'vm'){
   if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id)}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
-  desktopAgent.value=agent;computerOpen.value=true
+  desktopAgent.value=agent;desktopBackend.value=backend;computerOpen.value=true
 }
 async function leaveComputer(){
   if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
@@ -1017,7 +1017,7 @@ onBeforeUnmount(() => {
     </section>
     <WorkspaceBotPanel v-if="dockView&&active" :conversation-id="active.id" :task-id="activeTask?.id" :mode="dockView" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="dockView=undefined" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="openComputer" @workspace="desktopAgent=$event;twoDesktops=true" />
     </div>
-    <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
+    <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" :backend="desktopBackend" auto-take @changed="load()" @close="computerOpen=false" />
     <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @open="searchActionError = ''" @select="select">
       <template #item-actions="{ item }">

+ 6 - 6
src/server/computerControls.ts

@@ -38,15 +38,15 @@ export class ComputerControlService {
         return {agent,available,reason}
       })}
     })
-    router.get('/api/app/agents/:id/computer',async ctx=>{
+    router.get('/api/app/agents/:id/computer',async (ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
       ctx.body=await this.hub.computer(owner,agent,'status',{},()=>{this.agent(owner,agent.id)})
     })
-    router.get('/api/app/agents/:id/computer/frame',async ctx=>{
+    router.get('/api/app/agents/:id/computer/frame',async (ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
       ctx.set('Cache-Control','no-store');ctx.body=await this.hub.computer(owner,agent,'frame',{},()=>{this.agent(owner,agent.id)})
     })
-    router.post('/api/app/agents/:id/computer/take',async ctx=>{
+    router.post('/api/app/agents/:id/computer/take',async (ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
       const old=this.store.list<Grant>('_system','computer-control').find(grant=>grant.owner===owner&&(grant.environmentId??grant.agentId)===(agent.computerEnvironmentId??agent.id)&&grant.expiresAt>Date.now()&&this.allowed(grant.id,grant.runnerId))
       if(old&&(old.agentId!==agent.id||old.requestId!==input.requestId))throw new HttpError(409,'电脑已由另一个页面控制,关闭原页面或等待控制权到期','computer_control_busy')
@@ -57,16 +57,16 @@ export class ComputerControlService {
       const status=await this.hub.computer(owner,agent,'take',{controlId:grant.id},()=>{if(!this.allowed(grant.id,runnerId))throw new HttpError(403,'电脑控制请求已失效','computer_control_expired')})
       ctx.body={...status,controlId:grant.id,token,expiresAt:grant.expiresAt}
     })
-    router.post('/api/app/agents/:id/computer/renew',ctx=>{
+    router.post('/api/app/agents/:id/computer/renew',(ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,grant=this.token(owner,ctx.params.id,(ctx.request as any).body)
       grant.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',grant.id,grant);ctx.body={expiresAt:grant.expiresAt}
     })
-    router.post('/api/app/agents/:id/computer/input',async ctx=>{
+    router.post('/api/app/agents/:id/computer/input',async (ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({controlId:z.string().uuid(),token:z.string(),requestId:z.string().uuid(),generation:z.number().int().nonnegative(),frameId:z.string().uuid(),action:z.record(z.string(),z.unknown())}).strict(),(ctx.request as any).body)
       const grant=this.token(owner,agent.id,input)
       ctx.body=await this.hub.computer(owner,agent,'input',{controlId:grant.id,requestId:input.requestId,generation:input.generation,frameId:input.frameId,action:input.action},()=>{this.token(owner,agent.id,input)})
     })
-    router.post('/api/app/agents/:id/computer/giveback',async ctx=>{
+    router.post('/api/app/agents/:id/computer/giveback',async (ctx,next)=>{if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='vm')return next();
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({controlId:z.string().uuid(),token:z.string(),notes:z.string().max(4000).default('')}).strict(),(ctx.request as any).body)
       const grant=this.token(owner,agent.id,input)
       ctx.body=await this.hub.computer(owner,agent,'giveback',{controlId:grant.id,notes:input.notes},()=>{this.token(owner,agent.id,input)})

+ 3 - 3
src/server/desktopEnvironments.ts

@@ -213,10 +213,10 @@ export class DesktopEnvironments {
    ctx.body=await pending})
   router.put('/api/app/agents/:id/browser-profile',ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id),body=parse(z.object({profile:z.enum(['persistent','temporary'])}).strict(),(ctx.request as any).body);this.assertIdle(owner,a.id);this.store.updateAgent(owner,a.id,{browserProfile:body.profile});ctx.body={ok:true}})
   router.get('/api/app/agents/:id/browser',async ctx=>{const owner=this.auth.require(ctx).id,a=this.agent(owner,ctx.params.id);if(this.selected(owner,a)!=='browser')throw new HttpError(409,'请先选择仅浏览器环境','desktop_unselected');const s=this.sessionFor(a);ctx.body=await this.command(s!,{...this.context(s,owner,a,'browser').command,operation:'browser-state'},this.context(s,owner,a,'browser').check)})
-  router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(this.sessionFor(a),owner,a,mode)})
-  router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode);ctx.set('Cache-Control','no-store');c.check();ctx.body=await this.capture(s!,c)})
+  router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='desktop')return next();if(!mode)return next();this.agent(owner,a.id);ctx.body=this.status(this.sessionFor(a),owner,a,mode)})
+  router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='desktop')return next();if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode);ctx.set('Cache-Control','no-store');c.check();ctx.body=await this.capture(s!,c)})
   router.post('/api/app/agents/:id/computer/:action',async(ctx,next)=>{
-   const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode),body=(ctx.request as any).body,action=ctx.params.action;c.check()
+   const owner=this.auth.require(ctx).id,a=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id),mode=this.selected(owner,a);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='desktop')return next();if(!mode)return next();this.agent(owner,a.id);const s=this.sessionFor(a),c=this.context(s,owner,a,mode),body=(ctx.request as any).body,action=ctx.params.action;c.check()
    if(action==='take'){
     const {requestId}=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(s!,c.key)
     if(old){if(old.owner!==owner||old.agentId!==a.id||old.requestId!==requestId)throw new HttpError(409,'已有页面正在控制这台电脑','computer_busy');while(!old.ready){c.check();if(this.current(s!,c.key)!==old)throw new HttpError(410,'接管已失效','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,80))}ctx.body={...this.status(s,owner,a,mode),token:old.token};return}

+ 3 - 3
src/server/grokCloud.ts

@@ -212,11 +212,11 @@ export class GrokCloud {
     router.post('/api/app/agents/:id/cloud-computer/open',async ctx=>{const owner=this.auth.require(ctx).id;this.authorize(owner,ctx.params.id);await this.connect(owner);ctx.body={ok:true}})
     // These routes precede the local computer router and preserve its wire
     // protocol so the desktop and iOS takeover viewers share one UI contract.
-    router.use('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.state.grokOwner=owner;return next()})
+    router.use('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='cloud')return next();if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.state.grokOwner=owner;return next()})
     router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=ctx.state.grokOwner;if(!owner)return next();ctx.body=await this.status(owner)})
-    router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.set('Cache-Control','no-store');ctx.body=await this.capture(owner)})
+    router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='cloud')return next();if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.set('Cache-Control','no-store');ctx.body=await this.capture(owner)})
     router.post('/api/app/agents/:id/computer/:action',async(ctx,next)=>{
-      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id)
+      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(typeof ctx.query.backend==='string'&&ctx.query.backend!=='cloud')return next();if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id)
       const body=(ctx.request as any).body,action=ctx.params.action
       if(action==='take'){
         const value=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(owner)