Просмотр исходного кода

fix(desktop): 电脑面板多环境轮询不再闪跳,接管查看器可切换本机/远程 Mac/云端/虚拟机

Samien 1 месяц назад
Родитель
Сommit
f64dc864f7

+ 40 - 6
src/client/components/workspace/ComputerPanel.vue

@@ -2,13 +2,15 @@
 import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
 import {apiRequest} from '@/api/client'
 import BrowserToolbar from './BrowserToolbar.vue'
-import type {BotBrowserState} from '@shared/desktopEnvironment'
+import {agentEnvs} from '@shared/workspace'
+import type {BotBrowserState,DesktopEnvironmentState} from '@shared/desktopEnvironment'
 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;backend?:'desktop'|'cloud'|'vm'}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
+const backend=ref(props.backend),host=ref(''),envTargets=ref<{hosts:DesktopEnvironmentState['hosts'];cloudConfigured:boolean;vmEnabled:boolean}>()
 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>()
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
@@ -19,8 +21,34 @@ const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行'
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
 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 targets=computed(()=>{
+  const agent=selectedAgent.value,envs=agent?agentEnvs(agent):{vm:false,cloud:false,desktop:false,browser:false},info=envTargets.value
+  const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';host?:string;disabled?:boolean}[]=[]
+  if(envs.desktop)for(const h of info?.hosts??[])list.push({key:'desktop:'+h.id,label:(h.id==='local'?'本机':'Mac')+' · '+(h.name||'未命名'),backend:'desktop',host:h.id,disabled:!h.online})
+  if(envs.cloud)list.push({key:'cloud',label:'云端 · Grok Bot'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
+  if(envs.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
+  return list
+})
+const currentKey=computed(()=>targets.value.find(t=>t.backend===backend.value&&(t.host??'')===host.value)?.key??'')
+async function loadTargets(){
+  if(!selected.value)return
+  const agent=selectedAgent.value,envs=agent?agentEnvs(agent):{vm:false,cloud:false,desktop:false,browser:false},id=encodeURIComponent(selected.value)
+  const [desk,cloud,vm]=await Promise.all([
+    envs.desktop?apiRequest<DesktopEnvironmentState>(`/api/app/agents/${id}/desktop-environment`).catch(()=>undefined):Promise.resolve(undefined),
+    envs.cloud?apiRequest<{configured:boolean}>(`/api/app/agents/${id}/cloud-computer`).catch(()=>undefined):Promise.resolve(undefined),
+    envs.vm?apiRequest<{enabled:boolean}>(`/api/app/agents/${id}/local-vm`).catch(()=>undefined):Promise.resolve(undefined),
+  ])
+  envTargets.value={hosts:(desk as DesktopEnvironmentState|undefined)?.hosts??[],cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled}
+}
+function switchTarget(key:string){
+  const next=targets.value.find(t=>t.key===key)
+  if(!next||next.disabled||key===currentKey.value)return
+  backend.value=next.backend;host.value=next.host??''
+  inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
+  void refresh()
+}
 const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
-const query=()=>props.backend?'?backend='+encodeURIComponent(props.backend):''
+const query=()=>{const q=new URLSearchParams();if(backend.value)q.set('backend',backend.value);if(backend.value==='desktop'&&host.value)q.set('host',host.value);const qs=q.toString();return qs?'?'+qs:''}
 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
@@ -38,7 +66,8 @@ async function refresh(){
       if(current===generation&&!closed)frame.value=image
     }else frame.value=undefined
     error.value=''
-  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;frame.value=undefined;inputs.length=0;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
+  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;frame.value=undefined;inputs.length=0;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';{const code=Number((cause as {status?:number}).status)
+      if([401,410].includes(code)||(code===403&&ticket.value)){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}}
   finally{polling=false}
 }
 async function run(action:()=>Promise<void>){
@@ -89,8 +118,12 @@ async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.val
 async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded&&!props.standalone)dialog.value?.close();emit('close')}}
 async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await dialog.value?.requestFullscreen()}catch{error.value='无法进入全屏,请使用窗口最大化'}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
-watch(selected,()=>{inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
-onMounted(()=>{if(!props.embedded&&!props.standalone)dialog.value?.showModal();void cycle();if(props.autoTake)void take()})
+watch(selected,()=>{
+  inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
+  backend.value=props.backend;host.value='';envTargets.value=undefined
+  void loadTargets().then(()=>{if(!currentKey.value){const first=targets.value.find(t=>!t.disabled);if(first)switchTarget(first.key)}});void refresh()
+})
+onMounted(()=>{if(!props.embedded&&!props.standalone)dialog.value?.showModal();void loadTargets().then(()=>{if(!currentKey.value){const first=targets.value.find(t=>!t.disabled);if(first)switchTarget(first.key)}});void cycle();if(props.autoTake)void take()})
 onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
 defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
 </script>
@@ -98,7 +131,8 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
   <Teleport to="body" :disabled="embedded||standalone">
     <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="state.backend==='local'?'本机':state.backend==='browser'?'浏览器':'隔离电脑'" @cancel.prevent="close">
       <header>
-        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?'本机 · '+state.hostName:state.backend==='browser'?'浏览器 · '+state.hostName:'隔离电脑'}}</strong><small>{{ labels[state.mode] }}</small></div>
+        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'本机桌面'):state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ labels[state.mode] }}</small></div>
+        <select v-if="targets.length>1" :value="currentKey" aria-label="切换电脑环境" :disabled="!!ticket||busy" @change="switchTarget(($event.target as HTMLSelectElement).value)"><option v-for="target in targets" :key="target.key" :value="target.key" :disabled="target.disabled">{{ target.label }}</option></select>
         <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
         <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
         <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>

+ 20 - 11
src/client/components/workspace/LocalVmChatPanel.vue

@@ -14,10 +14,17 @@ const agent=computed(()=>props.agents.find(a=>a.id===selected.value))
 const native=ref<DesktopEnvironmentState>()
 const nativeSelected=computed(()=>native.value?.selected)
 const modern=computed(()=>!state.value?.fixedCapacity)
-const envs=computed(()=>agent.value?agentEnvs(agent.value):{vm:false,cloud:false,desktop:false,browser:false})
+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})
+watch(()=>agent.value?agentEnvs(agent.value):undefined,base=>{
+ if(!envsOverride.value||!base)return
+ const keys=Object.keys(envsOverride.value) as (keyof typeof base)[]
+ if(keys.every(key=>base[key]===envsOverride.value![key]))envsOverride.value=undefined
+})
 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}}}))
+ envsOverride.value={...envs.value,[key]:enabled}
+ void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{envs:{...envs.value}}})).then(ok=>{if(!ok)envsOverride.value=undefined})
 }
 const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
 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))
@@ -69,18 +76,18 @@ async function refresh(){
  const version=revision
  try{
   native.value=await apiRequest<DesktopEnvironmentState>(`/api/app/agents/${selected.value}/desktop-environment`);if(version!==revision||closed)return
+  let nativeNext:typeof state.value
   if(nativeSelected.value){
    const n=native.value;let ready=n.local.ready
    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}
+   nativeNext={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=''
   }
   const cloudState=await apiRequest<typeof cloud.value>(`/api/app/agents/${selected.value}/cloud-computer`);if(version!==revision||closed)return;cloud.value=cloudState
+  let cloudNext:typeof state.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&&!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=''
+   cloudNext={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(cloudNext.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
   }
   const wantVm=modern.value?(envs.value.vm||state.value===undefined):(!nativeSelected.value&&!cloudSelected.value)
   if(wantVm){
@@ -88,10 +95,12 @@ async function refresh(){
    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
   }
+  else if(nativeNext)state.value=nativeNext
+  else if(cloudNext)state.value=cloudNext
   error.value=''
  }catch(e){if(version===revision)error.value=e instanceof Error?e.message:'无法读取电脑画面'}finally{if(version===revision)loading.value=false}
 }
-async function run(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}}
+async function run(work:()=>Promise<unknown>){let ok=true;if(busy.value)return false;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){ok=false;error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}return ok}
 function choose(value:string){
  if(state.value?.fixedCapacity&&!['off','auto','cloud','local','browser','vm'].includes(value)){
    void run(()=>apiRequest(base(),{method:'PUT',body:{enabled:true,desktopId:value}}));return
@@ -115,7 +124,7 @@ function action(value:LocalVmAction){
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
 watch(()=>props.agents.map(a=>a.id).join(','),()=>{if(!props.agents.some(a=>a.id===selected.value))selected.value=props.agents[0]?.id??''},{immediate:true})
-watch(selected,()=>{revision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;envsOverride.value=undefined;clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 </script>
@@ -217,8 +226,8 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 .env-checks{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:4px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}
 .env-check{display:flex;align-items:flex-start;gap:9px;padding:11px 12px;border:1px solid transparent;border-radius:8px;cursor:pointer;min-width:0}
 .env-check:hover:not(:has(input:disabled)){background:var(--surface-hover)}
-.env-check.selected{border-color:var(--accent);background:var(--surface-raised)}
-.env-check input{flex:none;width:16px;height:16px;margin:1px 0 0;accent-color:var(--accent)}
+.env-check.selected{background:color-mix(in srgb,var(--success) 8%,transparent)}
+.env-check input{flex:none;width:16px;height:16px;margin:1px 0 0;accent-color:var(--success)}
 .env-check span{display:grid;gap:4px;min-width:0}
 .env-check b{display:flex;align-items:center;gap:6px;font-weight:600;font-size:12.5px;color:var(--text-primary)}
 .env-check small,.env-checks .env-hint{font-size:11px;line-height:1.5;color:var(--text-muted)}

+ 12 - 3
src/server/desktopEnvironments.ts

@@ -102,6 +102,15 @@ export class DesktopEnvironments {
  }
  private readySession(owner:string){for(const s of this.sessions.values())if(this.hostOnline(s)&&this.ready(s,owner))return s;return undefined}
  /** Resolve the host a tool call addresses: explicit host name/id, else the agent binding. */
+ /** Resolve the host a manual takeover request addresses: explicit host
+  * id/name from the viewer switcher, else the agent binding. */
+ private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
+  if(typeof host==='string'&&host){
+   for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
+   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
+  }
+  return this.sessionFor(a)
+ }
  private targetFor(agent:WorkspaceAgent,host:unknown):HostState{
   if(typeof host==='string'&&host){
    for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
@@ -213,10 +222,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(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.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.takeoverSession(a,ctx.query.host),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.takeoverSession(a,ctx.query.host),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(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()
+   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.takeoverSession(a,ctx.query.host),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}

+ 21 - 3
tests/server/desktopEnvironments.test.ts

@@ -17,7 +17,8 @@ const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner'}),requireAdmin:(c
 const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
 const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
 beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Browser',nodeId:'local',profile:'default',computer:'browser'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
-afterEach(()=>{clearInterval(poller);service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
+const pollers=new Set<ReturnType<typeof setInterval>>()
+afterEach(()=>{clearInterval(poller);for(const p of pollers)clearInterval(p);pollers.clear();service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
 const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
 const base=()=>'/api/app/agents/'+agent.id
 it('adds an authorized native desktop to hybrid tools while leaving the default viewer on the VM',async()=>{
@@ -29,9 +30,9 @@ it('adds an authorized native desktop to hybrid tools while leaving the default
  agent=store.updateAgent('owner',agent.id,{computer:'vm',allowHostEnvironment:false});expect(service.toolMode('owner',agent)).toBeUndefined()
  await expect(service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)).rejects.toMatchObject({code:'computer_control_expired'})
 })
-function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});poller=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
+function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});const p=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
 const HOST_A='11111111-aaaa-4bbb-8ccc-111111111111',HOST_B='22222222-aaaa-4bbb-8ccc-222222222222'
-function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});poller=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
+function driveRemote(id:string,hostInfo:any,handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('remote frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.remoteExchange(id,{host:hostInfo,results});const p=setInterval(()=>{const value=service.remoteExchange(id,{host:hostInfo,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5);pollers.add(p);poller=p}
 it('routes a bound agent to its remote desktop host and keeps hosts isolated',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  const remoteHost={...host,name:'客厅 Mac Pro'}
@@ -62,6 +63,23 @@ it('requires an explicit binding when several remote hosts are connected',async(
  const ticket=await take()
  await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)
 })
+it('lets the manual viewer take over any connected host via the host parameter',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_B})
+ driveRemote(HOST_A,{...host,name:'书房'},c=>c.operation==='view'?{data:Buffer.from('study frame').toString('base64'),width:1280,height:800}:{ok:true})
+ driveRemote(HOST_B,{...host,name:'客厅'},c=>c.operation==='view'?{data:Buffer.from('living frame').toString('base64'),width:1280,height:800}:{ok:true})
+ const study='?backend=desktop&host='+encodeURIComponent(HOST_A)
+ const status=(await request(app()).get(base()+'/computer'+study)).body
+ expect(status.hostName).toBe('书房');expect(status.mode).toBe('idle')
+ const ticket=(await request(app()).post(base()+'/computer/take'+study).send({requestId:randomUUID()}).expect(200)).body
+ expect(ticket.hostName).toBe('书房')
+ const bound=(await request(app()).get(base()+'/computer?backend=desktop')).body
+ expect(bound.hostName).toBe('客厅');expect(bound.mode).toBe('idle')
+ const frame=await request(app()).get(base()+'/computer/frame'+study).expect(200)
+ expect(Buffer.from(frame.body.data,'base64').toString()).toBe('study frame')
+ await request(app()).post(base()+'/computer/giveback'+study).send({controlId:ticket.controlId,token:ticket.token}).expect(200)
+ const response=await request(app()).get(base()+'/computer?backend=desktop&host=nope').expect(409)
+ expect(response.body.code).toBe('desktop_host_unknown')
+})
 it('revoking a desktop host fences only its dispatched commands',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
  service.remoteExchange(HOST_A,{host,results:[]})