|
@@ -5,18 +5,18 @@ 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[]}>(),emit=defineEmits<{close:[];changed:[]}>()
|
|
|
|
|
|
|
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),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 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 managing=ref(false),shareName=ref('团队共享电脑'),shareMembers=ref<string[]>([]),trusted=ref(false)
|
|
|
|
|
|
|
+const controlsOpen=ref(false)
|
|
|
const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
|
|
const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
|
|
|
-const candidates=computed(()=>props.agents.filter(agent=>!agent.temporaryGoalId&&!agent.computerEnvironmentId&&!agent.archived))
|
|
|
|
|
-const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
|
|
|
|
|
|
|
+const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
|
|
|
let takeRequestId:string|undefined
|
|
let takeRequestId:string|undefined
|
|
|
let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
|
|
let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
|
|
|
-const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
|
|
|
|
|
|
|
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
|
|
|
const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
|
|
const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
|
|
|
-const controllable=computed(()=>mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation&&!busy.value)
|
|
|
|
|
|
|
+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 endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
|
|
const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
|
|
|
async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
|
|
async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
|
|
|
async function refresh(){
|
|
async function refresh(){
|
|
@@ -26,90 +26,105 @@ async function refresh(){
|
|
|
const status=await apiRequest<ComputerControlStatus>(base)
|
|
const status=await apiRequest<ComputerControlStatus>(base)
|
|
|
if(current!==generation||closed)return
|
|
if(current!==generation||closed)return
|
|
|
state.value=status
|
|
state.value=status
|
|
|
|
|
+ connectionOK.value=true
|
|
|
if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
|
|
if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
|
|
|
if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
|
|
if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
|
|
|
if(status.mode!=='off'){
|
|
if(status.mode!=='off'){
|
|
|
const image=await apiRequest<ComputerFrame>(base+'/frame')
|
|
const image=await apiRequest<ComputerFrame>(base+'/frame')
|
|
|
if(current===generation&&!closed)frame.value=image
|
|
if(current===generation&&!closed)frame.value=image
|
|
|
}
|
|
}
|
|
|
- }catch(cause){if(current===generation&&!closed){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;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'}}}}
|
|
|
finally{polling=false}
|
|
finally{polling=false}
|
|
|
}
|
|
}
|
|
|
async function run(action:()=>Promise<void>){
|
|
async function run(action:()=>Promise<void>){
|
|
|
if(busy.value)return;generation++;busy.value=true;error.value=''
|
|
if(busy.value)return;generation++;busy.value=true;error.value=''
|
|
|
- try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh()}
|
|
|
|
|
|
|
+ try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
|
|
|
}
|
|
}
|
|
|
-function take(){void run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
|
|
|
|
|
|
|
+function take(){return run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
|
|
|
|
|
+const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
|
|
|
function send(action:ComputerInput){
|
|
function send(action:ComputerInput){
|
|
|
- if(!controllable.value||!frame.value||!ticket.value)return
|
|
|
|
|
- const image=frame.value,credentials=ticket.value
|
|
|
|
|
- void run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})})
|
|
|
|
|
|
|
+ if(!canSend.value||closed)return
|
|
|
|
|
+ if(pendingClick){inputs.push(pendingClick);pendingClick=undefined;clearTimeout(clickTimer)}
|
|
|
|
|
+ const last=inputs.at(-1)
|
|
|
|
|
+ if(action.kind==='text'&&last?.kind==='text'&&last.text.length+action.text.length<=16000)last.text+=action.text
|
|
|
|
|
+ else if(inputs.length<64)inputs.push(action)
|
|
|
|
|
+ else {error.value='输入过快,请稍后再试';return}
|
|
|
|
|
+ void flushInputs()
|
|
|
}
|
|
}
|
|
|
|
|
+async function flushInputs(){
|
|
|
|
|
+ if(flushing||busy.value||!inputs.length||closed)return
|
|
|
|
|
+ if(!canSend.value||!frame.value||!ticket.value){inputs.length=0;return}
|
|
|
|
|
+ flushing=true
|
|
|
|
|
+ const action=inputs.shift()!,image=frame.value,credentials=ticket.value
|
|
|
|
|
+ try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
|
|
|
|
|
+ finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
|
|
|
|
|
+}
|
|
|
|
|
+function point(event:MouseEvent|PointerEvent){const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!;return {x:Math.min(f.width-1,Math.max(0,Math.floor((event.clientX-r.left)*f.width/r.width))),y:Math.min(f.height-1,Math.max(0,Math.floor((event.clientY-r.top)*f.height/r.height)))}}
|
|
|
|
|
+let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
|
|
|
|
|
+function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;const p=point(event);dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
|
|
|
|
|
+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);dragged=true;send({kind:'drag',fromX:start.x,fromY:start.y,toX:end.x,toY:end.y})}}
|
|
|
|
|
+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)))})}
|
|
|
|
|
+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})}
|
|
|
function click(event:MouseEvent){
|
|
function click(event:MouseEvent){
|
|
|
- if(!controllable.value||!frame.value)return
|
|
|
|
|
|
|
+ if(dragged){dragged=false;return}
|
|
|
|
|
+ if(!canSend.value||!frame.value)return
|
|
|
const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
|
|
const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
|
|
|
- send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
|
|
|
|
|
|
|
+ const action:ComputerInput={kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1}
|
|
|
|
|
+ if(event.button===2||event.detail>1){clearTimeout(clickTimer);pendingClick=undefined;send(action)}
|
|
|
|
|
+ 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)}
|
|
|
}
|
|
}
|
|
|
-function manage(){managing.value=!managing.value;shareMembers.value=candidates.value.map(agent=>agent.id);trusted.value=false}
|
|
|
|
|
-function share(){void run(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:shareName.value,memberIds:shareMembers.value,trusted:trusted.value}});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
|
|
|
|
|
-function unshare(){const id=selectedAgent.value?.computerEnvironmentId;if(!id)return;void run(async()=>{await apiRequest(`/api/app/computers/shared/${encodeURIComponent(id)}`,{method:'DELETE'});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
|
|
|
|
|
async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
|
|
async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
|
|
|
-async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
|
|
|
|
|
|
|
+async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded)dialog.value?.close();emit('close')}}
|
|
|
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,()=>{generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
|
|
|
|
|
-onMounted(()=>{dialog.value?.showModal();void cycle()})
|
|
|
|
|
-onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
|
|
|
|
|
|
|
+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)dialog.value?.showModal();void cycle();if(props.autoTake)void take()})
|
|
|
|
|
+onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
|
|
|
|
|
+defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
|
|
|
</script>
|
|
</script>
|
|
|
<template>
|
|
<template>
|
|
|
- <Teleport to="body">
|
|
|
|
|
- <dialog ref="dialog" class="computer-panel" aria-label="隔离电脑" @cancel.prevent="close">
|
|
|
|
|
|
|
+ <Teleport to="body" :disabled="embedded">
|
|
|
|
|
+ <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
|
|
|
<header>
|
|
<header>
|
|
|
<div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
|
|
<div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
|
|
|
<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>{{ agents[0]?.name }}</span>
|
|
|
|
|
- <button type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
|
|
|
|
|
|
|
+ <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&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
|
|
|
|
|
+ <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
|
|
|
|
|
+ <button v-if="!embedded" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
|
|
|
</header>
|
|
</header>
|
|
|
- <div class="computer-screen" :class="{controlling:controllable}">
|
|
|
|
|
- <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" @click="click" @contextmenu.prevent="click">
|
|
|
|
|
|
|
+ <div class="computer-screen" :class="{controlling:canSend}">
|
|
|
|
|
+ <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
|
|
|
<p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
|
|
<p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
|
|
|
- <span v-if="frame&&!controllable" class="view-label">{{ state.mode==='off'?'上次画面':'仅查看' }}</span>
|
|
|
|
|
|
|
+ <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
|
|
|
</div>
|
|
</div>
|
|
|
<p v-if="error" class="computer-error" role="alert">{{ error }}</p>
|
|
<p v-if="error" class="computer-error" role="alert">{{ error }}</p>
|
|
|
- <footer>
|
|
|
|
|
|
|
+ <footer v-if="!embedded||controlsOpen">
|
|
|
<p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
|
|
<p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
|
|
|
- <button v-if="!ticket&&(candidates.length>1||selectedAgent?.computerEnvironmentId)" type="button" :disabled="busy" @click="manage">{{ managing?'返回电脑':'电脑共享设置' }}</button>
|
|
|
|
|
- <section v-if="managing" class="sharing-settings" aria-label="电脑共享设置">
|
|
|
|
|
- <template v-if="selectedAgent?.computerEnvironmentId">
|
|
|
|
|
- <p>解除共享后,成员回到各自原有的独立电脑;共享工作区保留,可由执行节点备份或恢复。</p>
|
|
|
|
|
- <button type="button" :disabled="busy" @click="unshare">解除这台电脑的共享</button>
|
|
|
|
|
- </template>
|
|
|
|
|
- <template v-else>
|
|
|
|
|
- <label>共享电脑名称<input v-model="shareName" maxlength="100"></label>
|
|
|
|
|
- <fieldset><legend>选择可信成员(同一来源和 Profile)</legend><label v-for="agent in candidates" :key="agent.id" class="share-option"><input v-model="shareMembers" type="checkbox" :value="agent.id">{{ agent.name }} · {{ agent.profile }}</label></fieldset>
|
|
|
|
|
- <p>新建共享工作区,原独立电脑的文件仍然保留。请先停止所选成员的任务并交还电脑控制权。</p>
|
|
|
|
|
- <label class="share-option"><input v-model="trusted" type="checkbox">我确认这些成员可以访问同一工作区和浏览器登录资料</label>
|
|
|
|
|
- <button type="button" :disabled="busy||!trusted||shareMembers.length<2||shareMembers.length>8||!shareName.trim()" @click="share">创建可信共享电脑</button>
|
|
|
|
|
- </template>
|
|
|
|
|
- </section>
|
|
|
|
|
<p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
|
|
<p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
|
|
|
- <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
|
|
|
|
|
|
|
+ <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
|
|
|
<template v-if="mine">
|
|
<template v-if="mine">
|
|
|
|
|
+ <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
|
|
|
|
|
+ <section v-if="controlsOpen" class="manual-input">
|
|
|
<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>
|
|
<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>
|
|
|
|
|
+ <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:['ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:['ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:['ctrl']})">粘贴</button></div>
|
|
|
<label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
|
|
<label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
|
|
|
<button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
|
|
<button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
|
|
|
|
|
+ </section>
|
|
|
<label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉 Agent 你完成了什么,以及接下来需要做什么" /></label>
|
|
<label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉 Agent 你完成了什么,以及接下来需要做什么" /></label>
|
|
|
<button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
|
|
<button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
|
|
|
<small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
|
|
<small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
|
|
|
</template>
|
|
</template>
|
|
|
</footer>
|
|
</footer>
|
|
|
- </dialog>
|
|
|
|
|
|
|
+ </component>
|
|
|
</Teleport>
|
|
</Teleport>
|
|
|
</template>
|
|
</template>
|
|
|
<style scoped>
|
|
<style scoped>
|
|
|
|
|
+.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}
|
|
|
.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}
|
|
.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}
|
|
|
.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}
|
|
.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}
|
|
|
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}
|
|
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}
|
|
|
-.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 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}
|
|
|
|
|
|
|
+.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}
|
|
|
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}
|
|
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}
|
|
|
.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}
|
|
.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}
|
|
|
@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}}
|
|
@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}}
|