| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- <script setup lang="ts">
- import {ref,computed,watch,onBeforeUnmount} from 'vue'
- 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,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,backend?:'desktop'|'cloud'|'vm',host?:string];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(()=>!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)[]
- 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
- 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))
- const showCloud=computed(()=>cloudSelected.value&&(!nativeSelected.value||modern.value))
- const showVm=computed(()=>modern.value?(envs.value.vm&&!!state.value):(!!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)
- 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('本地虚拟机')
- return parts.join(' · ')
- })
- const previewTargets=computed(()=>{
- const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';host?:string}[]=[]
- if(envs.value.desktop)for(const h of desktopHosts.value){if(h.online)list.push({key:'desktop:'+h.id,label:h.id==='local'?'本机 · '+(h.name||'桌面端'):h.name,backend:'desktop',host:h.id})}
- if(envs.value.cloud&&cloud.value?.configured)list.push({key:'cloud',label:'云端 · Grok Bot',backend:'cloud'})
- if(envs.value.vm)list.push({key:'vm',label:'本地虚拟机',backend:'vm'})
- return list
- })
- const previewKey=ref('')
- const previewTarget=computed(()=>previewKey.value?previewTargets.value.find(t=>t.key===previewKey.value):undefined)
- watch(previewTargets,list=>{if(previewKey.value&&!list.some(t=>t.key===previewKey.value))previewKey.value=''},{deep:true})
- const previewEmptyText=computed(()=>{
- const target=previewTarget.value
- if(!target)return ''
- if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return host?.local.ready?'正在读取本机画面…':'请完成本机控制授权'}
- if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
- if(!state.value?.enabled)return '未使用电脑'
- if(!state.value.image)return '本地虚拟机尚未准备'
- if(state.value.container==='missing')return '尚未创建虚拟机'
- if(state.value.container==='stopped')return '虚拟机已停止'
- return '正在连接桌面…'
- })
- function authorizeHost(hostId:string,scope?:'full'){
- const body:{hostId?:string;scope?:'full'}={}
- if(hostId)body.hostId=hostId
- if(scope)body.scope=scope
- void run(()=>apiRequest(`/api/app/agents/${selected.value}/desktop-environment/authorize`,{method:'POST',body,timeoutMs:125000}))
- }
- const computerChoices=computed(()=>[
- {value:'auto',label:'自动',description:'按可用环境自动选择',icon:'bolt' as const,disabled:false},
- {value:'cloud',label:'云端 · Grok Bot',description:'Grok Bot 虚拟机,工作留在云端',icon:'globe' as const,disabled:false},
- ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:(desktop.imageKey==='cursor'?'Cursor Universal · ':'标准桌面 · ')+(desktop.ready?'已就绪':'尚未就绪'),icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
- {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!desktopHosts.value.some(host=>host.local.supported)},
- {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!desktopHosts.value.some(host=>host.browser.available)},
- {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
- ])
- const computerChoiceDisabled=computed(()=>loading.value||!state.value||busy.value||state.value.inUse||!!agent.value?.remoteAgentId||!!agent.value?.temporaryGoalId)
- function selectComputer(value:string){
- if(computerChoiceDisabled.value||value===computerChoice.value||!computerChoices.value.some(option=>option.value===value&&!option.disabled))return
- choose(value)
- }
- function moveComputerTab(event:KeyboardEvent){
- const tabs=[...(computerTabs.value?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')??[])]
- const index=tabs.indexOf(event.target as HTMLButtonElement)
- if(index<0||!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return
- event.preventDefault()
- const next=event.key==='Home'?0:event.key==='End'?tabs.length-1:(index+(event.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length
- tabs[next]?.focus()
- }
- let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0
- const base=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/local-vm`
- async function refresh(){
- if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
- 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}
- nativeNext={enabled:true,container:ready?'running':'stopped',ready,image:true,mode:'shared',maxInstances:1,inUse:false}
- if(!previewTarget.value){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}
- }
- 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){
- 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(!previewTarget.value){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){
- const result=await apiRequest<typeof state.value>(base());if(version!==revision||closed)return;state.value=result
- if(!previewTarget.value){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
- const target=previewTarget.value
- if(target){
- const framePath=`/api/app/agents/${selected.value}/computer/frame?backend=${target.backend}`+(target.host?'&host='+encodeURIComponent(target.host):'')
- const usable=target.backend==='vm'?state.value?.container==='running'&&state.value?.ready
- :target.backend==='cloud'?!!cloud.value?.running&&!!cloud.value?.connected
- :true
- if(usable){try{const image=await apiRequest<ComputerFrame>(framePath);if(version===revision&&!closed)frame.value=image}catch{if(version===revision&&!closed)frame.value=undefined}}
- else if(version===revision)frame.value=undefined
- }
- 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}
- 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
- }
- void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:value}}))
- }
- function allowHostEnvironment(enabled:boolean){
- if(!canAllowHost.value||computerChoiceDisabled.value)return
- const computer=agent.value?.computer==='cloud'?'cloud':'vm'
- void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer,allowHostEnvironment:enabled}}))
- }
- 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(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)}
- async function openPreview(){
- if(!agent.value)return
- const target=previewTarget.value
- if(!target){await openDesktop();return}
- if(target.backend==='cloud'){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}
- emit('desktop',agent.value,target.backend,target.host)
- }
- function action(value:LocalVmAction){
- if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
- if(value==='remove'&&!confirm('移除这台虚拟机实例以切换镜像?工作文件和浏览器资料会保留。'))return
- void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
- }
- 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;envsOverride.value=undefined;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
- watch(()=>props.active,value=>{if(value)void refresh()})
- onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
- </script>
- <template>
- <aside class="local-vm-chat-panel" :class="{embedded}" aria-label="机器人电脑面板">
- <header><strong>电脑</strong><button aria-label="关闭电脑面板" @click="emit('close')"><AppIcon name="close"/></button></header>
- <div class="panel-body">
- <label v-if="agents.length>1" class="agent-select">机器人<select v-model="selected" :disabled="busy"><option v-for="item in agents" :key="item.id" :value="item.id">{{item.name}}</option></select></label>
- <p v-if="!agent">当前聊天没有可用的机器人。</p>
- <template v-else>
- <div class="screen-caption"><span>{{agent.name}}的电脑</span><select v-if="previewTargets.length>1" v-model="previewKey" class="screen-select" aria-label="显示的桌面"><option value="">自动</option><option v-for="target in previewTargets" :key="target.key" :value="target.key">{{target.label}}</option></select><small v-else-if="activeEnvLine">{{activeEnvLine}}{{nativeSelected&&nativeSelected==='local'&&native?.host?.name?' · '+native.host.name:''}}</small></div>
- <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openPreview()">
- <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?'正在检查电脑…':previewTarget?previewEmptyText: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>
- </button>
- <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
- <div class="computer-select">
- <span>此机器人使用的电脑</span>
- <div v-if="modern" class="env-checks" role="group" aria-label="此机器人使用的电脑(可多选)">
- <label class="env-check" :class="{selected:envs.vm}"><input type="checkbox" aria-label="虚拟环境" :checked="envs.vm" :disabled="computerChoiceDisabled" @change="toggleEnv('vm',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>虚拟环境</b><small>服务端上的隔离桌面,操作只在虚拟机内</small></span></label>
- <label class="env-check" :class="{selected:envs.cloud}"><input type="checkbox" aria-label="云虚拟机" :checked="envs.cloud" :disabled="computerChoiceDisabled" @change="toggleEnv('cloud',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>云虚拟机</b><small>共享云端电脑,持续保存工作</small></span></label>
- <label class="env-check" :class="{selected:envs.desktop}"><input type="checkbox" aria-label="脚本机" :checked="envs.desktop" :disabled="computerChoiceDisabled" @change="toggleEnv('desktop',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="monitor" :size="14"/>脚本机</b><small>{{native?.hosts?.length?`${native.hosts.filter((h:any)=>h.online).length} 台 Mac 在线,可按名选择`:'暂无已连接的 Mac'}}</small></span></label>
- <label class="env-check" :class="{selected:envs.browser}"><input type="checkbox" aria-label="脚本机浏览器" :checked="envs.browser" :disabled="computerChoiceDisabled" @change="toggleEnv('browser',($event.target as HTMLInputElement).checked)"><span><b><AppIcon name="globe" :size="14"/>脚本机浏览器</b><small>只使用客户那台机器上的浏览器</small></span></label>
- <p class="hint env-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>
- <div :id="`computer-options-${selected}`" class="computer-options" role="tabpanel" :aria-labelledby="`computer-tab-${selected}-${computerChoice}`">
- <p v-if="computerChoice==='auto'" class="hint">按已有可用环境自动选择。查看此面板不会创建或唤醒云端电脑。</p>
- <label v-if="localVmSelected&&!modern" class="vm-execution-option">执行方式
- <select aria-label="本地虚拟机执行方式" :value="agent.vmExecution??'worker'" :disabled="computerChoiceDisabled" @change="chooseVmExecution(($event.target as HTMLSelectElement).value)">
- <option value="worker">虚拟机模式</option><option value="profile">本机协作模式</option>
- </select>
- <span class="hint">{{agent.vmExecution==='profile'?'使用基础机器人的完整本机环境,同时操作这台虚拟机。文件和程序仍属于各自的电脑。':'使用独立的虚拟机执行环境,可按需允许本机文件和命令。'}}</span>
- </label>
- <label v-if="!modern&&canAllowHost&&(!localVmSelected||agent.vmExecution!=='profile')" class="host-environment-option"><input type="checkbox" :checked="agent.allowHostEnvironment===true" :disabled="computerChoiceDisabled" @change="allowHostEnvironment(($event.target as HTMLInputElement).checked)"> 允许本机环境</label>
- <template v-if="!modern&&canAllowHost&&agent.allowHostEnvironment">
- <p class="hint">允许 Bot 同时使用 Hermes 所在电脑的文件和命令。{{cloudSelected?'云端虚拟机仍使用独立的云端工具和文件。':'虚拟机工具仍在隔离 Linux 桌面内执行。'}}上方显示虚拟机画面。</p>
- <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="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>
- <div class="permission-list"><span>{{host.local.authorized?'✓':'○'}} 本机控制授权</span><span>{{host.local.screen?'✓':'○'}} 屏幕录制</span><span>{{host.local.accessibility?'✓':'○'}} 辅助功能</span><span>{{host.local.fullAuthorized?'✓':'○'}} 文件与命令</span></div>
- <button v-if="host.online&&!host.local.ready" class="primary" :disabled="busy||!isAdmin" @click="authorizeHost(host.id)">在桌面端授权</button>
- <p v-if="host.online&&!host.local.ready" class="hint">在 {{host.id==='local'?'这台':host.name}} Mac 上确认授权,并按系统提示开启权限;系统可能要求重新打开 App。</p>
- <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>
- <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>
- <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-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>
- <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-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(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>
- <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button><button :disabled="busy||state.inUse" @click="action('remove')">移除实例</button></div>
- <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':`重建会保留工作目录和浏览器资料。${vmIdleStopLabel(state.idleStopMinutes)},可在设置中修改。`}}</p>
- <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
- </template>
- </div>
- <button v-if="!cloudSelected" class="settings-link" @click="connectionSettings=!connectionSettings"><AppIcon name="globe" :size="14"/>Grok Bot 云端连接</button>
- <GrokAuthPanel v-if="connectionSettings" :is-admin="isAdmin" @changed="emit('changed');refresh()"/>
- </template>
- </div>
- </aside>
- </template>
- <style scoped>.host-environment-option{display:flex;align-items:center;gap:8px;min-height:44px;font-size:13px;cursor:pointer}.host-environment-option input{width:16px;height:16px;accent-color:var(--accent)}.vm-execution-option select{min-height:44px;min-width:0;width:100%}.vm-execution-option .hint{line-height:1.6}</style>
- <style scoped>
- .local-vm-chat-panel{width:400px;min-width:320px;max-width:46%;height:100%;border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;color:var(--text-primary)}header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--line)}header button{border:0;min-height:32px;padding:5px}.panel-body{overflow:auto;display:flex;flex-direction:column;gap:14px;padding:18px 20px}.screen-caption{display:flex;align-items:center;justify-content:space-between;font-size:13px;color:var(--text-secondary)}.screen-select{max-width:58%;min-height:30px;padding:4px 8px;font-size:12px;border-radius:7px}.preview{flex:none;position:relative;aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;border:0;border-radius:12px;overflow:hidden;padding:0;background:#14181e;color:#dce1e7;width:100%}.preview:disabled{opacity:1;cursor:default}.preview img{width:100%;height:100%;object-fit:contain}.empty{display:grid;justify-items:center;gap:12px;font-size:13px}.open-badge{position:absolute;right:8px;top:8px;display:flex;align-items:center;gap:4px;padding:5px 7px;background:#000b;color:white;border-radius:6px;font-size:11px}p{margin:0;font-size:13px;line-height:1.65;color:var(--text-secondary)}label{display:grid;gap:7px;font-size:13px}button,select{font:inherit;background:var(--surface-raised);color:inherit;border:1px solid var(--line);border-radius:8px;min-height:40px;padding:8px 12px}button{cursor:pointer;display:inline-flex;gap:7px;align-items:center;justify-content:center}button:disabled,select:disabled{opacity:.45;cursor:default}.primary{background:var(--accent);color:var(--text-on-solid)}.vm-actions{display:flex;gap:8px}.vm-actions button{flex:1;font-size:12px}.hint{font-size:12px;color:var(--text-muted)}.problem{color:var(--danger)}.settings-link{border:0;background:transparent;align-self:flex-start;padding-left:0;font-size:12px}.control-state{padding:10px;border-radius:8px;background:var(--surface-soft)}.host-permission{display:grid;gap:7px;padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}.host-permission.offline{opacity:.6}.host-permission-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.host-permission-head strong{font-size:12.5px}.host-permission-head small{font-size:11px;color:var(--text-muted)}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(max-width:900px){.local-vm-chat-panel{position:fixed;inset:0 0 0 auto;width:min(400px,100vw);max-width:100%;z-index:35;box-shadow:-20px 0 60px #0002}}
- .computer-select{display:grid;gap:7px;font-size:13px;min-width:0}
- .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{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)}
- .env-hint{grid-column:1/-1;margin:0}
- .computer-tabs{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);overflow-x:auto}
- .computer-tabs button{flex:1 0 auto;min-height:40px;padding:8px 12px;border:0;border-radius:7px;background:transparent;color:var(--text-secondary);font-size:12px;white-space:nowrap}
- .computer-tabs button[aria-selected="true"]{background:var(--surface);color:var(--text-primary);font-weight:600;box-shadow:0 1px 3px #00000014}
- .computer-tabs button:hover:not(:disabled):not([aria-selected="true"]){background:var(--surface-hover)}
- .computer-tabs button:focus-visible{outline-offset:-2px}
- .computer-options{display:flex;flex-direction:column;gap:14px}
- .computer-options:empty{display:none}
- </style>
- <style scoped>.cloud-settings{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:10px}.cloud-settings input{min-height:44px;font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;padding:8px;min-width:0}.computer-tabs{display:grid;grid-template-columns:1fr 1fr;overflow:visible}.computer-tabs button{text-align:left;justify-content:flex-start;white-space:normal;min-height:48px}</style>
- <style scoped>.computer-tabs{gap:8px;border:0;padding:0;background:transparent}.computer-tabs button{display:flex;flex-direction:column;align-items:flex-start;gap:7px;padding:12px;border:1px solid var(--line);background:var(--surface-soft);min-height:82px}.computer-tabs button span{display:flex;align-items:center;gap:6px;font-weight:500}.computer-tabs button small{font-size:11px;line-height:1.5;color:var(--text-muted)}.computer-tabs button[aria-selected=true]{border-color:var(--accent);background:var(--surface-raised);box-shadow:none}.computer-tabs button:disabled{opacity:.55}</style>
- <style scoped>.permission-list{display:grid;gap:8px;font-size:13px;color:var(--text-secondary)}</style>
|