ComputerPanel.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. <script setup lang="ts">
  2. import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
  3. import {apiRequest} from '@/api/client'
  4. import BrowserToolbar from './BrowserToolbar.vue'
  5. import type {BotBrowserState,DesktopEnvironmentState} from '@shared/desktopEnvironment'
  6. import {createUuid} from '@/utils/id'
  7. import type {WorkspaceAgent} from '@shared/workspace'
  8. import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
  9. import type {JsonValue} from '@shared/types'
  10. const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean;backend?:'desktop'|'cloud'|'vm',host?:string}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[];targetChanged:[backend:'desktop'|'cloud'|'vm',host?:string]}>()
  11. const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
  12. const backend=ref(props.backend),host=ref(props.host??(props.backend==='desktop'?'local':'')),envTargets=ref<{hosts:DesktopEnvironmentState['hosts'];cloudConfigured:boolean;vmEnabled:boolean}>()
  13. const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
  14. const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
  15. const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
  16. const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
  17. let takeRequestId:string|undefined
  18. let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
  19. const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'电脑空闲 · 仅查看',agent:'机器人正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还机器人',error:'电脑需要检查'}
  20. const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
  21. const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
  22. const controllable=computed(()=>canSend.value&&!busy.value)
  23. const computerSettings=ref({vm:true,cloud:true,desktop:true,browser:false})
  24. const targets=computed(()=>{
  25. const envs=computerSettings.value,info=envTargets.value
  26. const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';host?:string;disabled?:boolean}[]=[]
  27. if(envs.desktop)for(const h of info?.hosts??[])list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器':'客户端电脑')+' · '+(h.name||'未命名'),backend:'desktop',host:h.id,disabled:!h.online})
  28. if(envs.cloud)list.push({key:'cloud',label:'云端 · Grok Bot'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
  29. if(envs.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
  30. return list
  31. })
  32. const currentKey=computed(()=>targets.value.find(t=>t.backend===backend.value&&(t.host??'')===host.value)?.key??'')
  33. const targetAvailable=computed(()=>targets.value.some(t=>t.key===currentKey.value&&!t.disabled))
  34. watch([backend,host],([selectedBackend,selectedHost])=>{if(selectedBackend)emit('targetChanged',selectedBackend,selectedHost||undefined)},{immediate:true})
  35. async function loadTargets(){
  36. if(!selected.value)return
  37. const selectedId=selected.value,id=encodeURIComponent(selectedId)
  38. const settings=await apiRequest<{scriptMachine:boolean;serverComputer:boolean;vm:boolean;cloud:boolean}>('/api/app/settings/host-tools')
  39. if(closed||selectedId!==selected.value)return
  40. computerSettings.value={vm:settings.vm,cloud:settings.cloud,desktop:settings.scriptMachine||settings.serverComputer,browser:false}
  41. const [desk,cloud,vm]=await Promise.all([
  42. computerSettings.value.desktop?apiRequest<DesktopEnvironmentState>(`/api/app/agents/${id}/desktop-environment`).catch(()=>undefined):Promise.resolve(undefined),
  43. computerSettings.value.cloud?apiRequest<{configured:boolean}>(`/api/app/agents/${id}/cloud-computer`).catch(()=>undefined):Promise.resolve(undefined),
  44. computerSettings.value.vm?apiRequest<{enabled:boolean}>(`/api/app/agents/${id}/local-vm`).catch(()=>undefined):Promise.resolve(undefined),
  45. ])
  46. if(closed||selectedId!==selected.value)return
  47. envTargets.value={hosts:((desk as DesktopEnvironmentState|undefined)?.hosts??[]).filter(h=>h.id==='local'?settings.serverComputer:settings.scriptMachine),cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled}
  48. }
  49. async function switchTarget(key:string){
  50. const next=targets.value.find(t=>t.key===key)
  51. if(!next||next.disabled||key===currentKey.value)return
  52. if(ticket.value){
  53. try{await post('/giveback',{...ticket.value,notes:''})}catch(cause){error.value=cause instanceof Error?cause.message:'请先交还当前电脑';return}
  54. }
  55. backend.value=next.backend;host.value=next.host??''
  56. inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
  57. await refresh()
  58. }
  59. const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
  60. 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:''}
  61. async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
  62. async function refresh(){
  63. if(polling||busy.value||closed||!selected.value||document.hidden||!targetAvailable.value)return
  64. polling=true;const current=++generation,base=endpoint()
  65. try{
  66. const status=await apiRequest<ComputerControlStatus>(base+query())
  67. if(current!==generation||closed)return
  68. state.value=status
  69. connectionOK.value=true
  70. if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
  71. if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
  72. if(status.backend==='browser'){browserState.value=await apiRequest<BotBrowserState>(`/api/app/agents/${encodeURIComponent(selected.value)}/browser`)}else browserState.value=undefined
  73. if(status.mode!=='off'&&(status.backend!=='browser'||browserState.value?.open)){
  74. const image=await apiRequest<ComputerFrame>(base+'/frame'+query())
  75. if(current===generation&&!closed)frame.value=image
  76. }else frame.value=undefined
  77. error.value=''
  78. }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)
  79. if([401,410].includes(code)||(code===403&&ticket.value)){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}}
  80. finally{polling=false}
  81. }
  82. async function run(action:()=>Promise<void>){
  83. if(busy.value)return;generation++;busy.value=true;error.value=''
  84. try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
  85. }
  86. function take(){return run(async()=>{if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择');takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
  87. const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
  88. function send(action:ComputerInput){
  89. if(!canSend.value||closed)return
  90. if(pendingClick){inputs.push(pendingClick);pendingClick=undefined;clearTimeout(clickTimer)}
  91. const last=inputs.at(-1)
  92. if(action.kind==='text'&&last?.kind==='text'&&last.text.length+action.text.length<=16000)last.text+=action.text
  93. else if(inputs.length<64)inputs.push(action)
  94. else {error.value='输入过快,请稍后再试';return}
  95. void flushInputs()
  96. }
  97. async function flushInputs(){
  98. if(flushing||busy.value||!inputs.length||closed)return
  99. if(!canSend.value||!frame.value||!ticket.value){inputs.length=0;return}
  100. flushing=true
  101. const action=inputs.shift()!,image=frame.value,credentials=ticket.value
  102. try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
  103. finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
  104. }
  105. function browserAction(action:Record<string,string>){if(!canSend.value||!frame.value||!ticket.value)return;const f=frame.value;void run(async()=>{await post('/browser',{...ticket.value,requestId:createUuid(),generation:f.generation,frameId:f.id,action})})}
  106. function point(event:MouseEvent|PointerEvent){
  107. const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!
  108. const scale=Math.min(r.width/f.width,r.height/f.height),left=r.left+(r.width-f.width*scale)/2,top=r.top+(r.height-f.height*scale)/2
  109. const x=Math.floor((event.clientX-left)/scale),y=Math.floor((event.clientY-top)/scale)
  110. if(x<0||y<0||x>=f.width||y>=f.height)return
  111. return {x,y}
  112. }
  113. let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
  114. function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;(event.currentTarget as HTMLElement).focus({preventScroll:true});const p=point(event);if(!p)return;dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
  115. function up(event:PointerEvent){if(!dragStart)return;const start=dragStart;dragStart=undefined;if(!canSend.value)return;if(Math.hypot(event.clientX-start.clientX,event.clientY-start.clientY)>5){const end=point(event);if(!end)return;dragged=true;send({kind:'drag',fromX:start.x,fromY:start.y,toX:end.x,toY:end.y})}}
  116. function wheel(event:WheelEvent){if(!canSend.value)return;event.preventDefault();send({kind:'scroll',direction:Math.abs(event.deltaX)>Math.abs(event.deltaY)?event.deltaX>0?'right':'left':event.deltaY>0?'down':'up',amount:Math.min(20,Math.max(1,Math.ceil(Math.abs(event.deltaY||event.deltaX)/80)))})}
  117. function keydown(event:KeyboardEvent){if(!canSend.value)return;event.preventDefault();event.stopPropagation();if(event.key==='Escape'){(event.currentTarget as HTMLElement).blur();return}const modifiers=[event.ctrlKey?'ctrl':'',event.altKey?'alt':'',event.shiftKey?'shift':'',event.metaKey?'super':''].filter(Boolean);if(event.key.length===1&&!event.ctrlKey&&!event.altKey&&!event.metaKey)send({kind:'text',text:event.key});else if(!['Control','Alt','Shift','Meta'].includes(event.key))send({kind:'key',key:({Enter:'Return',ArrowUp:'Up',ArrowDown:'Down',ArrowLeft:'Left',ArrowRight:'Right',Backspace:'BackSpace'} as Record<string,string>)[event.key]??event.key,modifiers})}
  118. function click(event:MouseEvent){
  119. if(dragged){dragged=false;return}
  120. if(!canSend.value||!frame.value)return
  121. const p=point(event);if(!p)return
  122. const action:ComputerInput={kind:'click',...p,button:event.button===2?'right':'left',count:event.detail>1?2:1}
  123. if(event.button===2||event.detail>1){clearTimeout(clickTimer);pendingClick=undefined;send(action)}
  124. else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
  125. }
  126. async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
  127. 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')}}
  128. async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await dialog.value?.requestFullscreen()}catch{error.value='无法进入全屏,请使用窗口最大化'}}
  129. async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
  130. watch(selected,()=>{
  131. inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
  132. backend.value=props.backend;host.value=props.host??(props.backend==='desktop'?'local':'');envTargets.value=undefined
  133. void loadTargets().then(async()=>{if(!backend.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}await refresh()}).catch(cause=>{error.value=cause instanceof Error?cause.message:'无法读取电脑列表'})
  134. })
  135. onMounted(async()=>{
  136. if(!props.embedded&&!props.standalone)dialog.value?.showModal()
  137. try{
  138. await loadTargets()
  139. if(closed)return
  140. if(!backend.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}
  141. if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择;未打开其他电脑')
  142. if(props.autoTake&&!closed)await take()
  143. }catch(cause){error.value=cause instanceof Error?cause.message:'无法读取电脑列表'}
  144. if(!closed)void cycle()
  145. })
  146. onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
  147. defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
  148. </script>
  149. <template>
  150. <Teleport to="body" :disabled="embedded||standalone">
  151. <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="targets.find(t=>t.key===currentKey)?.label||'电脑桌面'" @cancel.prevent="close">
  152. <header>
  153. <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'服务器桌面'):state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ labels[state.mode] }}</small></div>
  154. <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>
  155. <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>
  156. <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
  157. <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
  158. <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
  159. <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
  160. <template v-if="!embedded">
  161. <button v-if="mine" type="button" :aria-expanded="controlsOpen" @click="controlsOpen=!controlsOpen">键盘与操作</button>
  162. <button type="button" :aria-pressed="actualSize" @click="actualSize=!actualSize">{{actualSize?'适应窗口':'原始大小'}}</button>
  163. <button type="button" @click="fullscreen">全屏</button>
  164. </template>
  165. <button v-if="!embedded" class="return-control" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
  166. </header>
  167. <BrowserToolbar v-if="state.backend==='browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
  168. <div class="computer-screen" :class="{controlling:canSend,'actual-size':actualSize}">
  169. <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="(targets.find(t=>t.key===currentKey)?.label||'电脑')+'当前画面'" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
  170. <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
  171. <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
  172. </div>
  173. <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
  174. <footer v-if="controlsOpen||(!embedded&&!ticket)">
  175. <p v-if="state.backend==='grok'">多个机器人共享同一台 Grok Bot 云端电脑、工作文件与浏览器资料。</p>
  176. <p v-else-if="state.backend==='local'">当前显示{{state.hostName||'所选电脑'}}的桌面,点击、输入、按键和滚动都会发送到这台电脑。</p>
  177. <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,可同时执行命令与文件操作,桌面操作交替进行;人工接管时机器人暂停等待。</p>
  178. <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
  179. <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy||!targetAvailable" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
  180. <template v-if="mine">
  181. <section v-if="controlsOpen" class="manual-input">
  182. <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
  183. <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">粘贴</button></div>
  184. <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
  185. <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
  186. </section>
  187. <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉机器人你完成了什么,以及接下来需要做什么" /></label>
  188. <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还机器人并继续' : '结束控制' }}</button>
  189. <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
  190. </template>
  191. </footer>
  192. </component>
  193. </Teleport>
  194. </template>
  195. <style scoped>
  196. .computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}
  197. .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
  198. .computer-panel.standalone{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;margin:0;border:0;border-radius:0;overflow:hidden}
  199. .standalone .computer-screen{flex:1;min-height:0;overflow:hidden}.standalone .computer-screen img{height:100%;width:100%;max-height:100%;max-width:100%;object-fit:contain}.standalone header{flex-shrink:0;background:#1c1f23;color:#f4f4f5}.standalone header small{color:#bec4cc}.standalone header button,.standalone header select{background:#2b2f34;color:#f4f4f5;border-color:#474c54}.standalone header .return-control{background:#f4f4f5;color:#202327;border-color:#f4f4f5}.standalone footer{position:absolute;right:16px;top:78px;width:min(360px,calc(100vw - 64px));max-height:calc(100dvh - 130px);overflow:auto;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 50px #0006;z-index:2}
  200. .computer-panel:fullscreen{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;border:0;border-radius:0}.computer-panel:fullscreen .computer-screen{flex:1;min-height:0}.computer-panel:fullscreen .computer-screen img{max-height:100%}
  201. .computer-screen.actual-size{overflow:auto;align-items:flex-start;justify-content:flex-start}.computer-screen.actual-size img{width:auto;height:auto;max-width:none;max-height:none;flex:none}.return-control{font-weight:600}.standalone .computer-error{flex:none;margin:0;padding:10px 18px}
  202. .computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
  203. button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  204. .computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen img:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
  205. footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
  206. .sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
  207. @media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
  208. </style>