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

修复聊天电脑面板缺少共享桌面连接入口

samien 1 месяц назад
Родитель
Сommit
6e089fdd43

+ 25 - 5
src/client/components/workspace/LocalVmChatPanel.vue

@@ -17,6 +17,16 @@ const showDesktop=computed(()=>computers.value.scriptMachine||computers.value.se
 const cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
 const showCloud=computed(()=>computers.value.cloud)
 const showVm=computed(()=>computers.value.vm&&!!state.value)
+const composeDesktopId=ref('')
+const composeDesktop=computed(()=>state.value?.desktops?.find(d=>d.id===composeDesktopId.value))
+const composeConnected=computed(()=>!!state.value?.enabled&&state.value.desktopId===composeDesktopId.value)
+const canConnectCompose=computed(()=>state.value?.fixedCapacity&&!busy.value&&!state.value.inUse&&!agent.value?.temporaryGoalId&&!composeConnected.value&&composeDesktop.value?.online&&composeDesktop.value.ready&&composeDesktop.value.available!==false)
+watch(()=>state.value,value=>{
+ if(!value?.fixedCapacity){composeDesktopId.value='';return}
+ if(value.desktops?.some(d=>d.id===composeDesktopId.value))return
+ const available=value.desktops?.filter(d=>d.online&&d.ready&&d.available!==false)??[]
+ composeDesktopId.value=value.desktopId??(available.length===1?available[0]!.id:'')
+})
 const desktopHosts=computed(()=>(native.value?.hosts??[]).filter(host=>host.id==='local'?computers.value.serverComputer:computers.value.scriptMachine))
 const serverHost=computed(()=>desktopHosts.value.find(host=>host.id==='local'))
 const activeEnvLine=computed(()=>[
@@ -43,7 +53,7 @@ const previewEmptyText=computed(()=>{
  if(!target)return previewKey.value?'所选电脑已不可用,请重新选择':'暂无已开放的电脑'
  if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
- if(!state.value?.enabled)return '未使用电脑'
+ if(!state.value?.enabled)return state.value?.fixedCapacity?'请先连接下方的共享桌面':'未使用电脑'
  if(state.value.problem)return state.value.problem
  if(!state.value.image)return '本地虚拟机尚未准备'
  if(state.value.container==='missing')return '尚未创建虚拟机'
@@ -92,7 +102,12 @@ async function refresh(){
   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>){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}
+async function run(work:()=>Promise<unknown>){let failure='';if(busy.value)return false;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){failure=e instanceof Error&&e.message?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh();if(failure)error.value=failure}return !failure}
+async function connectCompose(){
+ if(!canConnectCompose.value)return
+ const id=selected.value,desktopId=composeDesktopId.value
+ if(await run(()=>apiRequest(`/api/app/agents/${encodeURIComponent(id)}/local-vm`,{method:'PUT',body:{enabled:true,desktopId}}))&&selected.value===id)previewKey.value='vm'
+}
 async function openDesktop(backend:'desktop'|'cloud'|'vm',host?:string){
  const selectedAgent=agent.value
  if(!selectedAgent||busy.value)return
@@ -110,7 +125,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++;previewRevision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;previewRevision++;state.value=undefined;composeDesktopId.value='';native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer)})
 </script>
@@ -149,13 +164,18 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
      <p v-if="!isAdmin&&!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
     </template>
     <template v-if="showVm&&state">
-     <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
+     <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.fixedCapacity||state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
      <p v-if="state.problem" class="problem" role="alert" aria-label="本地虚拟机状态">{{state.problem}}</p>
+     <template v-if="state.fixedCapacity&&!agent.temporaryGoalId">
+      <label>共享桌面<select v-model="composeDesktopId" aria-label="共享桌面" :disabled="busy||state.inUse"><option value="" disabled>请选择共享桌面</option><option v-for="desktop in state.desktops" :key="desktop.id" :value="desktop.id" :disabled="!desktop.online||!desktop.ready||desktop.available===false">{{desktop.name}}{{desktop.available===false?' · 不可用':!desktop.online?' · 离线':!desktop.ready?' · 正在启动':''}}</option></select></label>
+      <button class="primary" data-testid="connect-compose-desktop" :disabled="!canConnectCompose" @click="connectCompose">{{composeConnected?'已连接':'连接所选桌面'}}</button>
+      <p v-if="!state.desktops?.length" class="hint">暂无可连接的共享桌面,请检查本地虚拟机设置。</p>
+     </template>
      <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>
       <p class="hint">{{state.mode==='shared'?'同一共享桌面只能使用一个镜像,选择会对所有共享成员生效。':'此桌面的镜像、文件和浏览器资料与其他独立桌面分开。'}}{{state.container!=='missing'?'切换镜像前请先移除实例。':''}}</p>
      </template>
-     <button v-if="!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
+     <button v-if="!state.fixedCapacity&&!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
      <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'">

+ 108 - 0
tests/client/composeDesktopConnection.test.ts

@@ -0,0 +1,108 @@
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {flushPromises,mount} from '@vue/test-utils'
+import Panel from '../../src/client/components/workspace/LocalVmChatPanel.vue'
+import {apiRequest} from '../../src/client/api/client'
+
+vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
+const agent={id:'bot',name:'小民',nodeId:'local',profile:'default',execution:'profile',computer:'auto'} as any
+const desktop={id:'cursor',name:'Cursor 开发桌面',online:true,ready:true,available:true}
+let state:any,problem:string
+const mountPanel=()=>mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
+beforeEach(()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ problem=''
+ state={fixedCapacity:true,enabled:false,desktops:[{...desktop}]}
+ vi.mocked(apiRequest).mockImplementation(async(path,options:any)=>{
+  if(path.endsWith('/host-tools'))return {vm:true,cloud:false,serverComputer:false,scriptMachine:false} as any
+  if(path.endsWith('/desktop-environment'))return {hosts:[]} as any
+  if(path.endsWith('/cloud-computer'))return {configured:false} as any
+  if(path.includes('/computer/frame?backend=vm'))return {data:'fixture',width:100,height:80} as any
+  if(options?.method==='PUT'){
+   if(problem)throw new Error(problem)
+   state={...state,desktopId:options.body.desktopId,enabled:true,container:'running',ready:true,image:true,mode:'shared'}
+   return {agent:{...agent,computerEnvironmentId:state.desktopId}} as any
+  }
+  return state
+ })
+})
+afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
+
+it('connects an unassigned bot to an existing Compose desktop and opens the VM viewer',async()=>{
+ const wrapper=mountPanel()
+ try{
+  await flushPromises()
+  expect(wrapper.get('[aria-label="共享桌面"]').element).toHaveProperty('value','cursor')
+  expect(vi.mocked(apiRequest).mock.calls.some(([,o])=>o?.method==='PUT')).toBe(false)
+  await wrapper.get('[data-testid="connect-compose-desktop"]').trigger('click');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/bot/local-vm',{method:'PUT',body:{enabled:true,desktopId:'cursor'}})
+  expect(wrapper.emitted('changed')).toHaveLength(1)
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,fixture')
+  expect(wrapper.findAll('button').some(b=>b.text()==='设置本地虚拟机')).toBe(false)
+  await wrapper.findAll('button').find(b=>b.text()==='打开桌面')!.trigger('click')
+  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'vm',undefined])
+ }finally{wrapper.unmount()}
+})
+
+it.each([
+ {online:false}, {ready:false}, {available:false},
+])('does not connect an unavailable desktop: %j',async unavailable=>{
+ state.desktops=[{...desktop,...unavailable}]
+ const wrapper=mountPanel()
+ try{
+  await flushPromises()
+  expect(wrapper.get('[aria-label="共享桌面"] option[value="cursor"]').attributes('disabled')).toBeDefined()
+  expect(wrapper.get('[data-testid="connect-compose-desktop"]').attributes('disabled')).toBeDefined()
+ }finally{wrapper.unmount()}
+})
+
+it('requires an explicit choice with several desktops and preserves it across refreshes',async()=>{
+ state.desktops.push({...desktop,id:'standard',name:'标准桌面'})
+ const wrapper=mountPanel()
+ try{
+  await flushPromises()
+  expect(wrapper.get('[data-testid="connect-compose-desktop"]').attributes('disabled')).toBeDefined()
+  await wrapper.get('[aria-label="共享桌面"]').setValue('standard')
+  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect(wrapper.get('[aria-label="共享桌面"]').element).toHaveProperty('value','standard')
+  await wrapper.get('[data-testid="connect-compose-desktop"]').trigger('click');await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/bot/local-vm',{method:'PUT',body:{enabled:true,desktopId:'standard'}})
+ }finally{wrapper.unmount()}
+})
+
+it('keeps a rejected binding visible and does not claim the desktop is connected',async()=>{
+ problem='请连接启用 Compose 桌面模式的执行节点'
+ const wrapper=mountPanel()
+ try{
+  await flushPromises();await wrapper.get('[data-testid="connect-compose-desktop"]').trigger('click');await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toBe(problem)
+  expect(wrapper.emitted('changed')).toBeUndefined()
+  expect(wrapper.findAll('button').some(b=>b.text()==='打开桌面')).toBe(false)
+ }finally{wrapper.unmount()}
+})
+
+it('keeps the existing assignment and blocks changes while the desktop is in use',async()=>{
+ state={...state,enabled:true,desktopId:'cursor',container:'running',ready:true,image:true,inUse:true}
+ const wrapper=mountPanel()
+ try{
+  await flushPromises()
+  expect(wrapper.get('[aria-label="共享桌面"]').element).toHaveProperty('value','cursor')
+  expect(wrapper.get('[aria-label="共享桌面"]').attributes('disabled')).toBeDefined()
+  expect(wrapper.get('[data-testid="connect-compose-desktop"]').text()).toBe('已连接')
+  expect(wrapper.get('[data-testid="connect-compose-desktop"]').attributes('disabled')).toBeDefined()
+ }finally{wrapper.unmount()}
+})
+
+it('does not reuse another bot selection or bind a desktop that became unavailable',async()=>{
+ state.desktops.push({...desktop,id:'standard',name:'标准桌面'})
+ const wrapper=mountPanel()
+ try{
+  await flushPromises();await wrapper.get('[aria-label="共享桌面"]').setValue('standard')
+  await wrapper.setProps({agents:[{...agent,id:'other'}]});await flushPromises()
+  expect(wrapper.get('[aria-label="共享桌面"]').element).toHaveProperty('value','')
+  await wrapper.get('[aria-label="共享桌面"]').setValue('standard')
+  state={...state,desktops:state.desktops.map((d:any)=>({...d,online:false}))}
+  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect(wrapper.get('[data-testid="connect-compose-desktop"]').attributes('disabled')).toBeDefined()
+  expect(vi.mocked(apiRequest).mock.calls.some(([,o])=>o?.method==='PUT')).toBe(false)
+ }finally{wrapper.unmount()}
+})