Răsfoiți Sursa

feat(bot): 电脑开关改为全局设置,虚拟环境先看桌面再操作

单个机器人不再勾选电脑。脚本机、服务器、虚拟环境和云虚拟机,以及文件访问和 Bot 记忆,都进 Bot 设置。本机只指夭夭所在的电脑。虚拟环境操作对齐云虚拟机,恢复会话时不再带 Hermes 不认的 disabled_toolsets。
Samien 1 lună în urmă
părinte
comite
997371b

+ 26 - 9
src/client/components/app/HostToolsPanel.vue

@@ -4,23 +4,36 @@ import { apiRequest } from '@/api/client'
 
 const emit = defineEmits<{ 'dirty-change': [value: boolean] }>()
 const deny = ref(true)
+const scriptMachine = ref(true)
+const serverComputer = ref(true)
+const vm = ref(true)
+const cloud = ref(true)
 const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
 
+interface Settings { denyServerTools: boolean; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
+function apply(value: Settings) {
+  deny.value = value.denyServerTools
+  scriptMachine.value = value.scriptMachine
+  serverComputer.value = value.serverComputer
+  vm.value = value.vm
+  cloud.value = value.cloud
+}
+function payload(): Settings {
+  return { denyServerTools: deny.value, scriptMachine: scriptMachine.value, serverComputer: serverComputer.value, vm: vm.value, cloud: cloud.value }
+}
 async function load() {
   loading.value = true; error.value = ''
   try {
-    const value = await apiRequest<{ denyServerTools: boolean }>('/api/app/settings/host-tools')
-    deny.value = value.denyServerTools
+    apply(await apiRequest<Settings>('/api/app/settings/host-tools'))
     emit('dirty-change', false)
-  } catch (cause) { error.value = cause instanceof Error ? cause.message : '无法读取服务端工具设置' }
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '无法读取电脑设置' }
   finally { loading.value = false }
 }
 function changed() { saved.value = false; emit('dirty-change', true) }
 async function save() {
   saving.value = true; error.value = ''; saved.value = false
   try {
-    const value = await apiRequest<{ denyServerTools: boolean }>('/api/app/settings/host-tools', { method: 'PUT', body: { denyServerTools: deny.value } })
-    deny.value = value.denyServerTools
+    apply(await apiRequest<Settings>('/api/app/settings/host-tools', { method: 'PUT', body: payload() }))
     saved.value = true; emit('dirty-change', false)
   } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
   finally { saving.value = false }
@@ -29,12 +42,16 @@ onMounted(load)
 </script>
 
 <template>
-  <section class="host-tools" aria-label="服务端工具">
-    <p>控制 Bot 在服务端本机上能不能用终端和文件。默认禁用。已勾选的脚本机、虚拟环境和云虚拟机不受这个开关影响。</p>
+  <section class="host-tools" aria-label="电脑与服务端工具">
+    <p>这些开关对所有机器人生效,不再按单个机器人勾选。关掉之后,新开或恢复的会话才会按新设置执行。</p>
     <form @submit.prevent="save" @change="changed">
-      <label class="toggle"><input v-model="deny" type="checkbox" :disabled="loading || saving" /><span><strong>禁用服务端本机的终端和文件</strong><small>开启后,Bot 对话不会拿到服务端的 terminal 和 file 工具。关掉之后,新开的会话才会放开,已经在跑的会话要重开一次。</small></span></label>
+      <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>脚本机</strong><small>客户连接的 Mac。机器人按名称点名,可以查看画面、操作、读写文件和执行命令。</small></span></label>
+      <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。查看本机只看它,不会跳到别的在线 Mac。</small></span></label>
+      <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
+      <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
+      <label class="toggle"><input v-model="deny" type="checkbox" :disabled="loading || saving" /><span><strong>禁用服务端本机的终端和文件</strong><small>默认开启。开启后对话拿不到服务端的 terminal 和 file 工具。这和上面的电脑开关是分开的。</small></span></label>
       <p v-if="error" class="error" role="alert">{{ error }}</p>
-      <p v-if="saved" role="status">已保存。下一次开始或恢复的 Bot 会话会按这个设置执行。</p>
+      <p v-if="saved" role="status">已保存。下一次开始或恢复的会话会按这个设置执行。</p>
       <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
     </form>
   </section>

+ 0 - 14
src/client/components/app/SettingsCenterDialog.vue

@@ -10,8 +10,6 @@ import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
-import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
-import HostToolsPanel from '@/components/app/HostToolsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import type { ProfileIdentityInput } from '@/api/profiles'
@@ -26,11 +24,8 @@ type SettingsPage =
   | 'account-mobile'
   | 'appearance'
   | 'system-overview'
-  | 'system-file-access'
-  | 'system-host-tools'
   | 'system-users'
   | 'system-connection'
-  | 'system-memory'
   | 'system-push'
   | 'system-nodes'
   | 'system-voice'
@@ -114,11 +109,8 @@ const accountItems = computed<NavigationItem[]>(() => [
 const systemItems: NavigationItem[] = [
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
-  { key: 'system-memory', label: 'Bot 记忆', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-nodes', label: '节点与设备', icon: 'panel' },
-  { key: 'system-file-access', label: '文件访问', icon: 'panel' },
-  { key: 'system-host-tools', label: '服务端工具', icon: 'panel' },
   { key: 'system-push', label: '消息推送', icon: 'bell' },
   { key: 'system-voice', label: '双流语音', icon: 'audio' },
   { key: 'system-update', label: '更新与回滚', icon: 'refresh' },
@@ -143,11 +135,8 @@ const activeTitle = computed(() => ({
   'account-mobile': '手机登录',
   appearance: '外观',
   'system-overview': '系统概览',
-  'system-file-access': '文件访问',
-  'system-host-tools': '服务端工具',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
-  'system-memory': 'Bot 记忆',
   'system-push': '消息推送',
   'system-nodes': '节点与设备',
   'system-voice': '双流语音',
@@ -386,11 +375,8 @@ function requestModeSwitch() {
                   <p class="appearance-note"><AppIcon name="info" :size="16"/>更改仅影响当前浏览器。</p>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
-                <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
-                <HostToolsPanel v-else-if="activePage === 'system-host-tools' && isAdmin" @dirty-change="setDirty('system-host-tools', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
-                <SystemManagementPanel v-else-if="activePage === 'system-memory' && isAdmin" section="memory" :active="true" @dirty-change="setDirty('system-memory', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
                 <WorkspaceNodesPanel v-else-if="activePage === 'system-nodes' && isAdmin" />
                 <section v-else-if="activePage === 'system-voice' && isAdmin"><DuplexVoicePanel @dirty-change="setDirty('system-voice', $event)" /><WorkspaceVoiceProviders /></section>

+ 16 - 4
src/client/components/workspace/BotSettingsDialog.vue

@@ -4,20 +4,29 @@ import {apiRequest} from '@/api/client'
 import AppIcon from '@/components/common/AppIcon.vue'
 import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
 import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
+import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
+import HostToolsPanel from '@/components/app/HostToolsPanel.vue'
+import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import type {WorkspaceAgent, WorkspaceConversation} from '@shared/workspace'
 
 const LocalVmSettingsPanel = defineAsyncComponent(() => import('@/components/app/LocalVmSettingsPanel.vue'))
-type Page = 'projects' | 'user' | 'vm'
+type Page = 'projects' | 'user' | 'vm' | 'computers' | 'files' | 'memory'
 const props = defineProps<{isAdmin?: boolean; initialPage?: Page}>()
 const emit = defineEmits<{close: []; changed: []}>()
-const page = ref<Page>(props.initialPage === 'vm' && !props.isAdmin ? 'projects' : props.initialPage ?? 'projects')
+const adminPages = new Set<Page>(['vm','computers','files','memory'])
+const page = ref<Page>(props.initialPage && adminPages.has(props.initialPage) && !props.isAdmin ? 'projects' : props.initialPage ?? 'projects')
 const agents = ref<WorkspaceAgent[]>([]), conversations = ref<WorkspaceConversation[]>([])
 const loading = ref(true), error = ref(''), knowledgeEnabled = ref(false)
 let closed = false
 const pages = computed(() => [
   {id:'projects' as const, label:'项目', icon:'files' as const},
   {id:'user' as const, label:'用户记忆', icon:'users' as const},
-  ...(props.isAdmin ? [{id:'vm' as const, label:'本地虚拟机', icon:'monitor' as const}] : []),
+  ...(props.isAdmin ? [
+    {id:'computers' as const, label:'电脑', icon:'monitor' as const},
+    {id:'files' as const, label:'文件访问', icon:'files' as const},
+    {id:'memory' as const, label:'Bot 记忆', icon:'users' as const},
+    {id:'vm' as const, label:'本地虚拟机', icon:'monitor' as const},
+  ] : []),
 ])
 async function load() {
   loading.value = true; error.value = ''
@@ -34,7 +43,7 @@ async function load() {
   finally { if (!closed) loading.value = false }
 }
 function changed() { emit('changed'); void load() }
-watch(() => props.isAdmin, allowed => { if (!allowed && page.value === 'vm') page.value = 'projects' })
+watch(() => props.isAdmin, allowed => { if (!allowed && adminPages.has(page.value)) page.value = 'projects' })
 onMounted(load)
 onBeforeUnmount(() => { closed = true })
 </script>
@@ -46,6 +55,9 @@ onBeforeUnmount(() => { closed = true })
         <button v-for="item in pages" :key="item.id" type="button" :aria-pressed="page === item.id" @click="page = item.id"><AppIcon :name="item.icon" :size="18" /><span>{{item.label}}</span></button>
       </nav>
       <section v-if="page === 'vm' && isAdmin" class="bot-settings__content bot-settings__vm"><header><h3>本地虚拟机</h3><p>为 Bot 配置隔离的工作环境。</p></header><LocalVmSettingsPanel /></section>
+      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>电脑</h3><p>脚本机、服务器、虚拟环境和云虚拟机对所有机器人一起开关。</p></header><HostToolsPanel /></section>
+      <section v-else-if="page === 'files' && isAdmin" class="bot-settings__content"><header><h3>文件访问</h3><p>控制聊天里服务器文件的预览和下载。</p></header><FileAccessPanel /></section>
+      <section v-else-if="page === 'memory' && isAdmin" class="bot-settings__content"><header><h3>Bot 记忆</h3><p>管理 Bot 使用的记忆服务。</p></header><SystemManagementPanel section="memory" :active="page === 'memory'" /></section>
       <section v-else class="bot-settings__content bot-settings__knowledge" :aria-label="page === 'projects' ? '项目设置' : '用户记忆设置'" :aria-busy="loading">
         <header v-if="page === 'user'"><h3>用户记忆</h3><p>管理同一账号的 Bot 共享的长期事实。</p></header>
         <p v-if="error" role="alert">{{error}} <button type="button" @click="load">重试</button></p>

+ 17 - 39
src/client/components/workspace/LocalVmChatPanel.vue

@@ -12,11 +12,12 @@ const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:Workspace
 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 computers=ref({scriptMachine:true,serverComputer:true,vm:true,cloud:true})
 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 showDesktop=computed(()=>modern.value?(computers.value.scriptMachine||computers.value.serverComputer):nativeSelected.value==='local')
 watch(()=>agent.value?agentEnvs(agent.value):undefined,base=>{
  if(!envsOverride.value||!base)return
  const keys=Object.keys(envsOverride.value) as (keyof typeof base)[]
@@ -28,19 +29,19 @@ function toggleEnv(key:'vm'|'cloud'|'desktop'|'browser',enabled:boolean){
  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 cloudSelected=computed(()=>modern.value?computers.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 showVm=computed(()=>modern.value?(computers.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 desktopHosts=computed(()=>(native.value?.hosts??[]).filter(host=>host.id==='local'?computers.value.serverComputer:computers.value.scriptMachine))
 const activeEnvLine=computed(()=>{
  const parts:string[]=[]
  if(modern.value){
-  if(envs.value.desktop)parts.push('本机控制')
-  if(envs.value.cloud)parts.push('云端')
-  if(envs.value.vm)parts.push('本地虚拟机')
+  if(computers.value.scriptMachine||computers.value.serverComputer)parts.push('脚本机')
+  if(computers.value.cloud)parts.push('云端')
+  if(computers.value.vm)parts.push('虚拟环境')
   return parts.join(' · ')
  }
  if(nativeSelected.value)parts.push('本机控制')
@@ -50,9 +51,9 @@ const activeEnvLine=computed(()=>{
 })
 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'})
+ if(computers.value.scriptMachine||computers.value.serverComputer)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(computers.value.cloud&&cloud.value?.configured)list.push({key:'cloud',label:'云端 · Grok Bot',backend:'cloud'})
+ if(computers.value.vm)list.push({key:'vm',label:'虚拟环境',backend:'vm'})
  return list
 })
 const previewKey=ref('')
@@ -101,6 +102,7 @@ async function refresh(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=revision
  try{
+  try{const tools=await apiRequest<{scriptMachine?:boolean;serverComputer?:boolean;vm?:boolean;cloud?:boolean}>('/api/app/settings/host-tools');if(version===revision&&!closed)computers.value={scriptMachine:tools.scriptMachine!==false,serverComputer:tools.serverComputer!==false,vm:tools.vm!==false,cloud:tools.cloud!==false}}catch{}
   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){
@@ -114,7 +116,7 @@ async function refresh(){
    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)
+  const wantVm=modern.value?(computers.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}
@@ -183,36 +185,12 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
      <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>
-      <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>
+    <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
+    <div class="computer-options">
     <template v-if="showDesktop">
-      <p>机器人默认在服务器({{native?.hosts?.some(h=>h.online)?'本机':'未连接'}})桌面上操作;已授权的其他 Mac 由机器人按名称点名操作,无需预先选择。</p>
+      <p>查看本机就是夭夭所在的这台电脑。其他脚本机按名称点名,不会因为只有一台在线就自动跳过去。</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="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>

+ 35 - 7
src/runner/worker/gateway.ts

@@ -23,7 +23,7 @@ import {homedir,platform} from 'node:os'
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean}
 export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string;hermesRuntime?:boolean}
-interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+interface Live {workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
@@ -31,13 +31,20 @@ const TOOLS:WorkerTool[]=[
   {name:'computer_shell',description:'在当前隔离电脑和 Profile 工作目录执行 shell 命令。默认使用 cua 用户;只有安装系统软件或修改系统路径时才选择 root。',inputSchema:object({command:text,user:{type:'string',enum:['cua','root'],default:'cua'}},['command'])},
   {name:'computer_read_file',description:'读取隔离电脑内的 UTF-8 文本文件。',inputSchema:object({path:text},['path'])},
   {name:'computer_write_file',description:'向隔离电脑写入 UTF-8 文本文件。',inputSchema:object({path:text,content:text},['path','content'])},
-  {name:'computer_desktop_state',description:'查看隔离电脑当前桌面,返回真实截图。',inputSchema:object({},[])},
-  {name:'computer_action',description:'在隔离电脑内调用 CUA 动作;先查看桌面并使用可用动作。',inputSchema:object({name:text,arguments:{type:'object'}},['name','arguments'])},
+  {name:'computer_desktop_state',description:'查看虚拟环境的真实桌面,返回截图和宽高。操作前必须先看。',inputSchema:object({},[])},
+  {name:'computer_action',description:'操作虚拟环境桌面。必须先查看桌面。kind 为 click、text、key 或 scroll,坐标用截图像素。',inputSchema:object({kind:{enum:['click','drag','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},fromX:{type:'integer'},fromY:{type:'integer'},toX:{type:'integer'},toY:{type:'integer'},button:{enum:['left','right','middle']},count:{type:'integer'},text:text,key:text,modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},['kind'])},
 ]
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
+const desktopAction=z.discriminatedUnion('kind',[
+ z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
+ z.object({kind:z.literal('drag'),fromX:z.number().int().nonnegative(),fromY:z.number().int().nonnegative(),toX:z.number().int().nonnegative(),toY:z.number().int().nonnegative()}).strict(),
+ z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
+ z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
+ z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
+])
 const PROFILE_COMPUTER_RULES='当前使用本机协作模式:沿用基础 Profile 的本机工具、配置和上下文,本地虚拟机是独立的 Linux 电脑。computer_shell/read_file/write_file/desktop_state/action/export 只操作虚拟机;本机文件、终端和程序使用 Profile 已授权的原生工具。computer_copy_file 在两个环境间显式复制文件。调用前明确目标环境,文件路径和浏览器登录互不通用;需要回传虚拟机文件时使用 computer_export。人工接管期间停止操作,交还后先检查当前状态。'
 const HERMES_COMPUTER_RULES='本轮由 Hermes 管理模型、配置、识图、技能和授权资源。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能;遵循当前 Profile 的技能规则。技能依赖本机登录态、环境变量或授权文件时,使用 Hermes 原生工具完成相应步骤;适合 Linux 的脚本与输入文件可经 computer_copy_file 传入虚拟机,再用 computer_shell 执行。路径、软件和登录态在本机与虚拟机之间不通用。不要自行复制 Profile 配置或凭据到虚拟机。'
-const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。命令、文件、浏览器和桌面操作都通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务端本机的终端、文件或桌面,也不要在节点离线时改去服务端或脚本机。脚本机和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
+const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。先看 computer_desktop_state,再按截图像素用 computer_action 点击、输入、按键或滚动。命令和文件也通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务端本机的终端、文件或桌面,也不要在节点离线时改去服务端或脚本机。脚本机和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
 const ISOLATED_HERMES_RULES='本轮由 Hermes 管理模型、配置和这次会话。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能。技能和 computer_* 都留在这台虚拟机里;不要把 Profile 的本机登录、文件或凭据复制进来。'
 export interface ProxyHandle {readonly proxy:ComputerPublicProxy;release():Promise<void>}
 export class ComputerRuntime {
@@ -418,15 +425,36 @@ export class ComputerGateway {
           await execute(['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',body.path],Buffer.from(body.content));return {ok:true}
         }
         if(name==='computer_action'){
-          const body=z.object({name:z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,99}$/),arguments:z.record(z.string(),z.unknown())}).strict().parse(args)
-          return execute([CUA_DRIVER,'call',body.name,JSON.stringify(body.arguments),'--socket',CUA_SOCKET],undefined,'gui')
+          if(!live.desktop)throw new HttpError(409,'请先查看虚拟环境桌面','computer_frame_stale')
+          const frame=live.desktop,action=desktopAction.parse(args),common={scope:'desktop',session:`yaoyao-vm-${live.session.id}`}
+          let tool:string,toolArgs:Record<string,unknown>,argv:string[]|undefined
+          if(action.kind==='click'){
+            if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
+            tool='click';toolArgs={...common,x:action.x,y:action.y,button:action.button,count:action.count}
+          }else if(action.kind==='drag'){
+            if(action.fromX>=frame.width||action.toX>=frame.width||action.fromY>=frame.height||action.toY>=frame.height)throw new HttpError(400,'拖动位置超出画面','computer_point_invalid')
+            tool='drag';toolArgs={}
+            argv=['env','DISPLAY=:1','xdotool','mousemove','--sync',String(action.fromX),String(action.fromY),'mousedown','1','mousemove','--sync',String(action.toX),String(action.toY),'mouseup','1']
+          }else if(action.kind==='text'){tool='type_text';toolArgs={...common,text:action.text}}
+          else if(action.kind==='key'){
+            const key=({Return:'enter',BackSpace:'backspace',Escape:'esc'} as Record<string,string>)[action.key]??action.key.toLowerCase()
+            tool=action.modifiers.length?'hotkey':'press_key';toolArgs=action.modifiers.length?{...common,keys:[...action.modifiers,key]}:{...common,key}
+          }else{tool='scroll';toolArgs={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
+          live.desktop=undefined
+          const response=await execute(argv??[CUA_DRIVER,'call',tool!,JSON.stringify(toolArgs!),'--socket',CUA_SOCKET],undefined,'gui')
+          let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
+          if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
+          return {ok:true}
         }
         if(name==='computer_desktop_state'){
           z.object({}).strict().parse(args)
           const path=`/tmp/yaoyao-frame-${randomUUID()}.png`
           const state=await execute([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',path],undefined,'gui')
           const data=(await execute(['base64','-w0',path])).stdout.trim()
-          return {_multimodal:true,text_summary:state.stdout,content:[{type:'image_url',image_url:{url:`data:image/png;base64,${data}`}}]}
+          const header=Buffer.from(data.slice(0,32),'base64'),width=header.length>=24?header.readUInt32BE(16):0,height=header.length>=24?header.readUInt32BE(20):0
+          if(width<1||height<1)throw new HttpError(502,'虚拟环境画面无效','computer_frame_failed')
+          live.desktop={width,height}
+          return {_multimodal:true,width,height,text_summary:state.stdout,content:[{type:'image_url',image_url:{url:`data:image/png;base64,${data}`}}]}
         }
         throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
       })

+ 28 - 21
src/server/desktopEnvironments.ts

@@ -6,7 +6,8 @@ import {HttpError} from './errors.js'
 import {parse,type WorkspaceStore} from './workspaceStore.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
-import {allowsHostEnvironment,agentEnvs,type WorkspaceAgent} from '../shared/workspace.js'
+import {allowsHostEnvironment,type WorkspaceAgent} from '../shared/workspace.js'
+import {readHostTools} from './hostToolSettings.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 
@@ -60,7 +61,7 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
  {id:'desktop_shell',name:'desktop_shell',description:'在这台电脑的用户主目录执行一条 shell 命令,返回 stdout、stderr 与退出码。长任务会超时终止;不要用它代替文件工具读写大文件。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{command:{type:'string'},cwd:{type:'string'},timeoutMs:{type:'integer'},host:{type:'string'}},required:['command'],additionalProperties:false}},
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_shell'])
-export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户所在的那台机器。使用 desktop_environment_view/action 查看和操作这台机器。先查看再操作,页面变化后重新获取截图;不得猜测旧坐标。desktop_file_list/read/write 在用户主目录范围内读写文件,desktop_shell 在主目录执行命令;这些是真实生效的操作。服务端本机、虚拟环境和云虚拟机都不是这台脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机。本机是夭夭正在运行的这台电脑。使用 desktop_environment_view/action 查看和操作;省略 host 或写「本机」只看这台电脑,其他脚本机用名称点名。先查看再操作,页面变化后重新获取截图;不得猜测旧坐标。desktop_file_list/read/write 在用户主目录范围内读写文件,desktop_shell 在主目录执行命令;这些是真实生效的操作。虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer
@@ -80,27 +81,39 @@ export class DesktopEnvironments {
  private readyFull(s:HostState,owner:string){return this.hostOnline(s)&&s.info!.platform==='darwin'&&(s.info!.full??[]).includes(this.ownerKey(owner))}
  fileTools(owner:string,agent:WorkspaceAgent){const s=this.sessionFor(agent);return !!s&&this.readyFull(s,owner)}
  private localSession(){let s=this.sessions.get('local');if(!s){s=newHostState('local',false);this.sessions.set('local',s)}return s}
- /** 脚本机 prefers the customer's machine. An explicit binding wins.
- * One online remote host is the customer machine. Several remotes need a binding.
- * The server loopback desktop is only used when no customer machine is online. */
+ /** 本机 is the computer running YaoYao. A non-local binding still wins for the
+ *  default session. Other script machines are selected by name, never by being the only one online. */
+ private computers(){try{const s=readHostTools(this.store.home);return {script:s.scriptMachine,server:s.serverComputer}}catch{return {script:false,server:false}}}
+ private hostOpen(s:HostState){const g=this.computers();return s.key==='local'?g.server:g.script}
  private sessionFor(agent:WorkspaceAgent):HostState|undefined{
-  if(agent.desktopHost){
+  const g=this.computers()
+  if(g.script&&agent.desktopHost&&agent.desktopHost!=='local'){
    const pinned=this.sessions.get(agent.desktopHost)
    if(pinned)return pinned
   }
-  const remote=[...this.sessions.values()].filter(h=>h.key!=='local'&&this.hostOnline(h))
-  if(remote.length===1)return remote[0]
-  if(remote.length>1)return undefined
+  if(!g.server)return undefined
   return this.sessions.get('local')
  }
- selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;const envs=agentEnvs(agent);if(agent.envs)return envs.desktop?'local':undefined;if(agent.computer==='local')return 'local';if(agent.computer==='auto'&&agent.execution!=='computer'){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return undefined}
+ private namedHost(host:string):HostState{
+  const g=this.computers()
+  if(host==='本机'||host==='local'){
+   if(!g.server)throw new HttpError(409,'全局设置未开放夭夭所在电脑的操作。','desktop_offline')
+   const local=this.sessions.get('local')
+   if(local&&this.hostOnline(local))return local
+   throw new HttpError(409,'夭夭所在的电脑未连接,请在这台电脑上打开夭夭。','desktop_offline')
+  }
+  if(!g.script)throw new HttpError(409,'全局设置未开放脚本机操作。','desktop_offline')
+  for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
+  throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
+ }
+ selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;const g=this.computers();if(!g.script&&!g.server)return undefined;if(agent.envs)return 'local';if(agent.computer==='local')return 'local';if(agent.computer==='auto'&&agent.execution!=='computer'){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return undefined}
  toolMode(owner:string,agent:WorkspaceAgent):Mode|undefined {if(allowsHostEnvironment(agent)){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return this.selected(owner,agent)}
  /** Intent for parallel desktop environments (screen control / browser). */
- modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean} {const envs=agentEnvs(agent);if(agent.envs)return {desktop:envs.desktop,browser:false};const mode=this.toolMode(owner,agent);return {desktop:mode==='local',browser:false}}
+ modes(owner:string,agent:WorkspaceAgent):{desktop:boolean,browser:boolean} {const g=this.computers(),desktop=g.script||g.server;if(agent.envs)return {desktop,browser:false};const mode=this.toolMode(owner,agent);return {desktop:desktop&&mode==='local',browser:false}}
  /** Availability for tool injection: degrade silently when hosts are offline. */
  envTools(owner:string,agent:WorkspaceAgent):{view:boolean,browser:boolean,file:boolean} {
   const wanted=this.modes(owner,agent)
-  const online=[...this.sessions.values()].filter(s=>this.hostOnline(s))
+  const online=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s))
   return {view:wanted.desktop&&online.some(s=>this.ready(s,owner)),browser:wanted.browser&&online.length>0,file:wanted.desktop&&online.some(s=>this.readyFull(s,owner))}
  }
  private readySession(owner:string){for(const s of this.sessions.values())if(this.hostOnline(s)&&this.ready(s,owner))return s;return undefined}
@@ -108,22 +121,16 @@ export class DesktopEnvironments {
  /** Resolve the host a manual takeover request addresses: explicit host
   * id/name from the viewer switcher, else the agent binding. */
  private takeoverSession(a:WorkspaceAgent,host:unknown):HostState|undefined{
-  if(typeof host==='string'&&host){
-   for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
-   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
-  }
+  if(typeof host==='string'&&host)return this.namedHost(host)
   return this.sessionFor(a)
  }
  private targetFor(agent:WorkspaceAgent,host:unknown):HostState{
-  if(typeof host==='string'&&host){
-   for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
-   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
-  }
+  if(typeof host==='string'&&host)return this.namedHost(host)
   const s=this.sessionFor(agent)
   if(!s)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
   return s
  }
- onlineHostsLine(owner:string){const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)).map(s=>s.info?.name??s.key);return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;未指定时使用默认电脑。`:''}
+ onlineHostsLine(owner:string){const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>s.key==='local'?'本机':(s.info?.name??s.key));return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;省略 host 或写「本机」时使用夭夭所在的这台电脑。`:''}
  private agent(owner:string,id:string){const a=this.store.require<WorkspaceAgent>(owner,'agent',id);this.nodes.requireSource(owner,a);if(a.archived||a.remoteAgentId||a.temporaryGoalId)throw new HttpError(409,'此机器人无法使用桌面环境','computer_unavailable');return a}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
  hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:info?.name??'',platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}

+ 2 - 1
src/server/grokCloud.ts

@@ -6,6 +6,7 @@ import {homedir} from 'node:os'
 import {join} from 'node:path'
 import {z} from 'zod'
 import {HttpError} from './errors.js'
+import {readHostTools} from './hostToolSettings.js'
 import {parse,type WorkspaceStore} from './workspaceStore.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
@@ -65,7 +66,7 @@ export class GrokCloud {
   preferDesktop?:(owner:string,agent:WorkspaceAgent)=>boolean
   beforeSelection?:(owner:string,agentId:string)=>void
   configured(owner:string){return !!this.store.get(owner,'grok-cloud','connection')}
-  selected(owner:string,agent:WorkspaceAgent){if(agent.envs)return agentEnvs(agent).cloud&&this.configured(owner);return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&!this.preferDesktop?.(owner,agent)&&this.configured(owner))}
+  selected(owner:string,agent:WorkspaceAgent){if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return false;let cloudOn=true;try{cloudOn=readHostTools(this.store.home).cloud}catch{cloudOn=false}if(!cloudOn)return false;if(agent.envs)return this.configured(owner);return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&!this.preferDesktop?.(owner,agent)&&this.configured(owner))}
   private credential(owner:string):Credential {const row=this.store.get<{sealed:string}>(owner,'grok-cloud','connection');if(!row)throw new HttpError(409,'请先连接 Grok Bot 云端账号','grok_cloud_not_configured');return this.nodes.open<Credential>(row.sealed)}
   private agent(owner:string,id:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',id);if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'此机器人不能连接云端电脑','grok_agent_unavailable');this.nodes.requireSource(owner,agent);return agent}
   private authorize(owner:string,agentId:string){const agent=this.agent(owner,agentId);if(!this.selected(owner,agent))throw new HttpError(410,'机器人已切换电脑','computer_control_expired');return agent}

+ 18 - 3
src/server/hostToolSettings.ts

@@ -4,19 +4,34 @@ import { randomUUID } from 'node:crypto'
 import { HttpError } from './errors.js'
 import { PROFILE_DENIED_TOOLSETS } from '../shared/workspace.js'
 
-export interface HostToolSettings { denyServerTools: boolean }
+export interface HostToolSettings {
+  denyServerTools: boolean
+  scriptMachine: boolean
+  serverComputer: boolean
+  vm: boolean
+  cloud: boolean
+}
 const settingsPath = (home: string) => join(home, 'host-tools.json')
+const COMPUTERS_ON = { scriptMachine: true, serverComputer: true, vm: true, cloud: true } as const
+const computerFlag = (input: Partial<HostToolSettings>, key: keyof typeof COMPUTERS_ON) =>
+  typeof input[key] === 'boolean' ? input[key] : COMPUTERS_ON[key]
 
 export function parseHostTools(value: unknown): HostToolSettings {
   const input = value as Partial<HostToolSettings> | null
   if (!input || typeof input.denyServerTools !== 'boolean')
     throw new HttpError(400, '请选择是否禁用服务端本机工具', 'invalid_host_tools')
-  return { denyServerTools: input.denyServerTools }
+  return {
+    denyServerTools: input.denyServerTools,
+    scriptMachine: computerFlag(input, 'scriptMachine'),
+    serverComputer: computerFlag(input, 'serverComputer'),
+    vm: computerFlag(input, 'vm'),
+    cloud: computerFlag(input, 'cloud'),
+  }
 }
 
 export function readHostTools(home: string): HostToolSettings {
   const path = settingsPath(home)
-  if (!existsSync(path)) return { denyServerTools: true }
+  if (!existsSync(path)) return { denyServerTools: true, ...COMPUTERS_ON }
   try { return parseHostTools(JSON.parse(readFileSync(path, 'utf8'))) }
   catch (error) {
     if (error instanceof HttpError) throw new HttpError(503, '服务端工具设置无法读取,请由管理员重新保存', 'host_tools_configuration_error')

+ 10 - 4
src/server/vmComputer.ts

@@ -7,12 +7,12 @@ export const VM_COMPUTER_TOOLS=[
   {id:'computer_shell',name:'computer_shell',description:'在虚拟环境里执行命令。虚拟环境是服务端上的隔离 Linux 电脑,不会在服务端本机或脚本机上执行。',inputSchema:schema({command:field,user:{type:'string',enum:['cua','root']}},['command'])},
   {id:'computer_read_file',name:'computer_read_file',description:'读取虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field},['path'])},
   {id:'computer_write_file',name:'computer_write_file',description:'写入虚拟环境里的 UTF-8 文件。',inputSchema:schema({path:field,content:field},['path','content'])},
-  {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的桌面,返回真实截图。',inputSchema:schema({},[])},
-  {id:'computer_action',name:'computer_action',description:'操作虚拟环境桌面。先查看桌面,再调用动作。',inputSchema:schema({name:field,arguments:{type:'object'}},['name','arguments'])},
+  {id:'computer_desktop_state',name:'computer_desktop_state',description:'查看虚拟环境的真实桌面,返回截图和宽高。操作前必须先看。',inputSchema:schema({},[])},
+  {id:'computer_action',name:'computer_action',description:'操作虚拟环境桌面。必须先查看桌面。kind 为 click、text、key 或 scroll,坐标用截图像素。',inputSchema:schema({kind:{enum:['click','drag','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},fromX:{type:'integer'},fromY:{type:'integer'},toX:{type:'integer'},toY:{type:'integer'},button:{enum:['left','right','middle']},count:{type:'integer'},text:field,key:field,modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},['kind'])},
   {id:'computer_export',name:'computer_export',description:'把虚拟环境里的文件作为附件回传到当前对话,最大 25 MiB。',inputSchema:schema({path:field,name:field},['path'])},
 ]
 
-export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔离 Linux 电脑,不是这段对话本身。查看、命令、文件和桌面都用 computer_*,不要用服务端本机的终端或文件代替。脚本机和云虚拟机是另外的工具,不要混用路径。'
+export const VM_COMPUTER_RULES='已勾选虚拟环境。它是服务端上的隔离 Linux 电脑,不是这段对话本身。先用 computer_desktop_state 看真实桌面,再用 computer_action 点击、输入、按键或滚动,坐标用刚看到的截图像素。命令和文件用 computer_shell、computer_read_file、computer_write_file。不要用服务端本机的终端或文件代替。脚本机和云虚拟机是另外的工具,不要混用路径。'
 
 export class VmToolSession {
   private gateway?: WorkspaceGateway
@@ -25,7 +25,13 @@ export class VmToolSession {
       this.sessionId=String(opened.session_id??'')
       if(!this.sessionId)throw new Error('虚拟环境没有打开')
     }
-    return this.gateway.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{}})
+    const value=await this.gateway.rpc('computer.invoke',{session_id:this.sessionId,name,arguments:args??{}})
+    if(name==='computer_desktop_state'&&value&&typeof value==='object'&&(value as any)._multimodal){
+      const url=String((value as any).content?.[0]?.image_url?.url??'')
+      const data=url.includes(',')?url.split(',')[1]:''
+      return {content:[{type:'image',mimeType:'image/png',data},{type:'text',text:JSON.stringify({width:(value as any).width,height:(value as any).height})}]}
+    }
+    return value
   }
   close(){const gateway=this.gateway;this.gateway=undefined;this.sessionId='';gateway?.close()}
 }

+ 11 - 7
src/server/workspaceRuntime.ts

@@ -18,8 +18,12 @@ import type {
   WorkspaceInteraction,
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
-import { personaSection, agentEnvs } from '../shared/workspace.js'
-import { deniedHostToolsets } from './hostToolSettings.js'
+import { personaSection } from '../shared/workspace.js'
+import { deniedHostToolsets, readHostTools } from './hostToolSettings.js'
+function globalComputers(home: string) {
+  try { return readHostTools(home) }
+  catch { return { denyServerTools: true, scriptMachine: false, serverComputer: false, vm: false, cloud: false } }
+}
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT, RESOURCE_WAIT_CODES } from './workspaceScheduler.js'
 import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
@@ -276,7 +280,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.nodes.requireSource(owner, agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
-      dispatchedVm = !!agent.envs && agentEnvs(agent).vm && !agent.remoteAgentId && !agent.temporaryGoalId,
+      dispatchedVm = globalComputers(this.store.home).vm && !!agent.envs && !agent.remoteAgentId && !agent.temporaryGoalId,
       computerExecution = !dispatchedVm && !!(agent.remoteAgentId || (agent.execution==='computer' && (!agent.envs || this.computerAvailable?.(owner,agent) !== false))),
       chatExecution = dispatchedVm ? 'profile' : computerExecution ? 'computer' : (agent.execution ?? 'profile'),
       target = computerExecution ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
@@ -622,7 +626,6 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             omit_messages: true,
             ...(recovering&&target.runner?.computer?{recoverOnly:true}:{}),
             close_on_disconnect: false,
-            disabled_toolsets: deniedToolsets,
           })
         } catch (error) {
           const code = error instanceof HttpError ? error.code : ''
@@ -781,9 +784,10 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const members = run.turnConfiguration!.members
         const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
         const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
-        const checked = agentEnvs(agent)
-        const open = [checked.desktop ? '脚本机' : '', checked.vm ? '虚拟环境' : '', checked.cloud ? '云虚拟机' : ''].filter(Boolean)
-        const envDirective = `同一条对话里,只使用已勾选的环境:${open.join('、') || '无'}。脚本机是客户所在的机器,虚拟环境是服务端上的隔离虚拟机,云虚拟机是 Grok Bot 虚拟机。没勾选的不要用。${deniedToolsets.length ? '服务端本机的终端和文件已全局禁用,不要用服务端本机顶上。' : '全局设置已允许使用服务端本机的终端和文件。'}`
+        const globals = globalComputers(this.store.home)
+        const eligible = !agent.remoteAgentId && !agent.temporaryGoalId && !agent.archived
+        const open = [eligible && (globals.scriptMachine || globals.serverComputer) ? '脚本机' : '', eligible && globals.vm && agent.envs ? '虚拟环境' : '', eligible && globals.cloud ? '云虚拟机' : ''].filter(Boolean)
+        const envDirective = `同一条对话里,只使用 Bot 设置里全局开放的环境:${open.join('、') || '无'}。脚本机里,本机是夭夭正在运行的这台电脑,省略 host 或写「本机」就看它,其他脚本机按名称点名。虚拟环境是服务端上的隔离虚拟机,云虚拟机是 Grok Bot 虚拟机。没开放的不要用。${deniedToolsets.length ? '服务端本机的终端和文件已全局禁用,不要用服务端本机顶上。' : '全局设置已允许使用服务端本机的终端和文件。'}`
         const rules = [
           envDirective,
           `你是 ${agent.name}。${agent.description ? `描述:${agent.description}\n\n` : ''}${personaSection(agent) ? `用户为它设定的工作规范:\n${personaSection(agent)}\n\n` : ''}以下是用户为这个独立机器人配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端机器人管理。' : agent.instructions}`,

+ 13 - 15
tests/client/localVmImages.test.ts

@@ -7,23 +7,22 @@ vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
 const images=[{key:'standard',name:'标准桌面',description:'XFCE',ready:true,imageId:'sha256:a'},{key:'cursor',name:'Cursor Universal',description:'Debian',ready:true,imageId:'sha256:b'}]
 const agent={id:'image-fixture',name:'镜像测试',computer:'vm',execution:'computer',nodeId:'local',profile:'default'} as any
 const vm={configured:true,runtime:'docker',daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false,images}
-it('replaces the legacy execution-mode select with parallel toggles and locks them while in use',async()=>{
+it('does not offer a per-bot computer picker',async()=>{
  vi.spyOn(document,'hidden','get').mockReturnValue(false)
- let inUse=false
  vi.mocked(apiRequest).mockImplementation(async path=>{
   if(path.endsWith('/desktop-environment'))return {selected:null,local:{supported:false},browser:{available:false}} as any
   if(path.endsWith('/cloud-computer'))return {configured:false} as any
-  return {...vm,enabled:true,container:'missing',ready:false,inUse} as any
+  if(path.endsWith('/host-tools'))return {denyServerTools:true,scriptMachine:true,serverComputer:true,vm:true,cloud:true} as any
+  return {...vm,enabled:true,container:'missing',ready:false,inUse:false} as any
  })
  const wrapper=mount(Panel,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
  try{
   await flushPromises()
   expect(wrapper.find('[aria-label="本地虚拟机执行方式"]').exists()).toBe(false)
-  expect((wrapper.get('[aria-label="本地虚拟机"]').element as HTMLInputElement).checked).toBe(true)
-  await wrapper.get('[aria-label="本机控制"]').setValue(true);await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{envs:{vm:true,cloud:false,desktop:true,browser:false}}})
-  inUse=true;await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
-  expect((wrapper.get('[aria-label="本机控制"]').element as HTMLInputElement).disabled).toBe(true)
+  expect(wrapper.find('[aria-label="此机器人使用的电脑(可多选)"]').exists()).toBe(false)
+  expect(wrapper.find('[aria-label="本机控制"]').exists()).toBe(false)
+  expect(wrapper.text()).toContain('电脑开关在 Bot 设置里')
+  expect(apiRequest).not.toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',expect.anything())
  }finally{wrapper.unmount()}
 })
 afterEach(()=>{vi.clearAllMocks();vi.restoreAllMocks()})
@@ -52,8 +51,8 @@ it('shows parallel toggles without a combined environment tab',async()=>{
  try{
   await flushPromises()
   expect(wrapper.find('[role="tab"][aria-label="本机 + 虚拟机"]').exists()).toBe(false)
-  await wrapper.get('[aria-label="云端 · Grok Bot"]').setValue(true);await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{envs:{vm:true,cloud:true,desktop:false,browser:false}}})
+  expect(wrapper.find('[aria-label="云端 · Grok Bot"]').exists()).toBe(false)
+  expect(wrapper.text()).toContain('电脑开关在 Bot 设置里')
  }finally{wrapper.unmount()}
 })
 it('checks desktop alongside cloud and never shows the legacy host option',async()=>{
@@ -67,9 +66,8 @@ it('checks desktop alongside cloud and never shows the legacy host option',async
  const wrapper=mount(Panel,{props:{agents:[cloudAgent],isAdmin:true,active:true},global:{stubs:{AppIcon:true,GrokAuthPanel:true}}})
  try{
   await flushPromises()
-  expect((wrapper.get('[aria-label="云端 · Grok Bot"]').element as HTMLInputElement).checked).toBe(true)
-  await wrapper.get('[aria-label="本机控制"]').setValue(true);await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{envs:{vm:false,cloud:true,desktop:true,browser:false}}})
+  expect(wrapper.find('[aria-label="云端 · Grok Bot"]').exists()).toBe(false)
+  expect(wrapper.find('.host-environment-option').exists()).toBe(false)
   for(const computer of ['auto','off','local','browser']){
    await wrapper.setProps({agents:[{...cloudAgent,computer}]});await flushPromises()
    expect(wrapper.find('.host-environment-option').exists()).toBe(false)
@@ -89,8 +87,8 @@ it('keeps Compose lifecycle controls hidden while enabling host tools for an alr
  try{
   await flushPromises();expect(settings.find('[aria-label="虚拟机空闲停止时间"]').exists()).toBe(false)
   expect(wrapper.findAll('button').some(b=>b.text()==='停止虚拟机')).toBe(false)
-  await wrapper.get('input[type="checkbox"]').setValue(true);await flushPromises()
-  expect(apiRequest).toHaveBeenCalledWith('/api/app/agents/image-fixture/computer-selection',{method:'PUT',body:{computer:'vm',allowHostEnvironment:true}})
+  expect(wrapper.find('.host-environment-option').exists()).toBe(false)
+  expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
  }finally{settings.unmount();wrapper.unmount()}
 })
 it('prepares the selected image in Settings and explains the shared image constraint',async()=>{

+ 9 - 0
tests/server/desktopEnvironments.test.ts

@@ -57,6 +57,15 @@ it('defaults bot work to the customer machine and uses that binding while the se
  expect(hosts.hosts.map((h:any)=>h.id)).toEqual(['local',HOST_A])
  expect(hosts.hosts.find((h:any)=>h.id===HOST_A)).toMatchObject({name:'客厅 Mac Pro',online:true})
 })
+it('keeps 本机 on the computer running YaoYao when another Mac is online',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local'})
+ drive()
+ driveRemote(HOST_A,{...host,name:'客厅 Mac'})
+ const state=(await request(app()).get(base()+'/desktop-environment')).body
+ expect(state.host.id).toBe('local')
+ const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent))
+ expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
+})
 it('requires an explicit binding when several remote hosts are connected',async()=>{
  agent=store.updateAgent('owner',agent.id,{computer:'local'})
  service.remoteExchange(HOST_A,{host:{...host,name:'书房'},results:[]})

+ 8 - 2
tests/server/hostToolSettings.test.ts

@@ -11,11 +11,17 @@ afterEach(() => { if (home) rmSync(home, { recursive: true, force: true }) })
 describe('host tool settings', () => {
   it('denies server tools until an admin turns the global option off', () => {
     home = mkdtempSync(join(tmpdir(), 'yaoyao-host-tools-'))
-    expect(readHostTools(home)).toEqual({ denyServerTools: true })
+    expect(readHostTools(home)).toEqual({ denyServerTools: true, scriptMachine: true, serverComputer: true, vm: true, cloud: true })
     expect(deniedHostToolsets(home)).toEqual(['terminal', 'file'])
-    expect(saveHostTools(home, { denyServerTools: false })).toEqual({ denyServerTools: false })
+    expect(saveHostTools(home, { denyServerTools: false })).toEqual({ denyServerTools: false, scriptMachine: true, serverComputer: true, vm: true, cloud: true })
     expect(deniedHostToolsets(home)).toEqual([])
     writeFileSync(join(home, 'host-tools.json'), '{')
     expect(deniedHostToolsets(home)).toEqual(['terminal', 'file'])
   })
+  it('keeps old files that only stored the server-tool switch', () => {
+    home = mkdtempSync(join(tmpdir(), 'yaoyao-host-tools-'))
+    writeFileSync(join(home, 'host-tools.json'), JSON.stringify({ denyServerTools: true }))
+    expect(readHostTools(home).scriptMachine).toBe(true)
+    expect(saveHostTools(home, { denyServerTools: true, scriptMachine: false, serverComputer: true, vm: false, cloud: true })).toMatchObject({ scriptMachine: false, vm: false, cloud: true })
+  })
 })