|
@@ -2,13 +2,15 @@
|
|
|
import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
|
|
import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
|
|
|
import {apiRequest} from '@/api/client'
|
|
import {apiRequest} from '@/api/client'
|
|
|
import BrowserToolbar from './BrowserToolbar.vue'
|
|
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 {createUuid} from '@/utils/id'
|
|
|
import type {WorkspaceAgent} from '@shared/workspace'
|
|
import type {WorkspaceAgent} from '@shared/workspace'
|
|
|
import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
|
|
import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
|
|
|
import type {JsonValue} from '@shared/types'
|
|
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 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 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 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 controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
|
|
|
const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
|
|
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 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 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 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 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 post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
|
|
|
async function refresh(){
|
|
async function refresh(){
|
|
|
if(polling||busy.value||closed||!selected.value||document.hidden)return
|
|
if(polling||busy.value||closed||!selected.value||document.hidden)return
|
|
@@ -38,7 +66,8 @@ async function refresh(){
|
|
|
if(current===generation&&!closed)frame.value=image
|
|
if(current===generation&&!closed)frame.value=image
|
|
|
}else frame.value=undefined
|
|
}else frame.value=undefined
|
|
|
error.value=''
|
|
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}
|
|
finally{polling=false}
|
|
|
}
|
|
}
|
|
|
async function run(action:()=>Promise<void>){
|
|
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 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 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)}
|
|
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)})
|
|
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})
|
|
defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
|
|
|
</script>
|
|
</script>
|
|
@@ -98,7 +131,8 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
|
|
|
<Teleport to="body" :disabled="embedded||standalone">
|
|
<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">
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<span v-else-if="!embedded">{{ agents[0]?.name }}</span>
|
|
|
<button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
|
|
<button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
|