فهرست منبع

feat(desktop-host): 电脑面板支持选择机器人操作的桌面主机

选择"本机/仅浏览器"后出现机器选择器(本机 + 各配对远程 Mac),
绑定保存在 agent.desktopHost,未绑定自动解析;
"在桌面端授权"按所选机器下发授权命令。
本机/浏览器标签在任一已连接主机支持时可选,不再只看单一解析结果。
Samien 1 ماه پیش
والد
کامیت
eaed7a632a

+ 19 - 4
src/client/components/workspace/LocalVmChatPanel.vue

@@ -18,12 +18,20 @@ const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.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 hostSelectable=computed(()=>desktopHosts.value.some(host=>host.id!=='local'))
+const boundHost=computed(()=>agent.value?.desktopHost||'')
+function chooseDesktopHost(value:string){
+ if(computerChoiceDisabled.value||!nativeSelected.value||value===boundHost.value)return
+ void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:nativeSelected.value as string,desktopHost:value||null}}))
+}
+const authorizeBody=():Record<string,string>=>boundHost.value?{hostId:boundHost.value}:{}
 const computerChoices=computed(()=>[
  {value:'auto',label:'自动',description:'按可用环境自动选择',icon:'bolt' as const,disabled:false},
  {value:'cloud',label:'云端 · Grok Bot',description:'共享云端电脑,持续保存工作',icon:'globe' as const,disabled:false},
  ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name,description:(desktop.imageKey==='cursor'?'Cursor Universal · ':'标准桌面 · ')+(desktop.ready?'已就绪':'尚未就绪'),icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
- {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!native.value?.local.supported},
- {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!native.value?.browser.available},
+ {value:'local',label:'本机',description:native.value?.host?.name??'请在电脑上打开夭夭桌面端',icon:'monitor' as const,disabled:!desktopHosts.value.some(host=>host.local.supported)},
+ {value:'browser',label:'仅浏览器',description:native.value?.browser.available?'独立浏览器,保存登录资料':'请在电脑上打开夭夭桌面端',icon:'globe' as const,disabled:!desktopHosts.value.some(host=>host.browser.available)},
  {value:'off',label:'不使用电脑',description:'使用基础机器人的其他工具',icon:'stop' as const,disabled:false},
 ])
 const computerChoiceDisabled=computed(()=>loading.value||!state.value||busy.value||state.value.inUse||!!agent.value?.remoteAgentId||!!agent.value?.temporaryGoalId)
@@ -125,13 +133,20 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <template v-if="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="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:{},timeoutMs:125000}))">授权本机桌面操作</button>
+     <button v-else-if="native?.local.supported" :disabled="busy||!isAdmin" @click="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:authorizeBody(),timeoutMs:125000}))">授权本机桌面操作</button>
     </template>
     <template v-if="nativeSelected">
      <p>{{nativeSelected==='local'?'机器人将查看并操作已连接电脑的真实桌面。':'浏览器在已连接的桌面端运行,机器人和你使用同一组标签页。'}}连接电脑:{{native?.host?.name??'未连接'}}。</p>
+     <label v-if="hostSelectable" class="desktop-host-option">操作的电脑
+      <select aria-label="操作的电脑" :value="boundHost" :disabled="busy" @change="chooseDesktopHost(($event.target as HTMLSelectElement).value)">
+       <option value="">自动选择</option>
+       <option v-for="host in desktopHosts" :key="host.id" :value="host.id">{{host.id==='local'?'本机':host.name||'桌面主机'}}{{host.online?'':' · 离线'}}</option>
+      </select>
+      <span class="hint">选择机器人在哪台电脑上查看和操作;每台电脑需单独授权。</span>
+     </label>
      <template v-if="nativeSelected==='local'">
       <div class="permission-list"><span>{{native?.local.authorized?'✓':'○'}} 本机控制授权</span><span>{{native?.local.screen?'✓':'○'}} 屏幕录制</span><span>{{native?.local.accessibility?'✓':'○'}} 辅助功能</span></div>
-      <button v-if="!native?.local.ready" class="primary" :disabled="busy||!native?.online||!isAdmin" @click="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:{},timeoutMs:125000}))">在桌面端授权</button>
+      <button v-if="!native?.local.ready" class="primary" :disabled="busy||!native?.online||!isAdmin" @click="run(()=>apiRequest(`/api/app/agents/${selected}/desktop-environment/authorize`,{method:'POST',body:authorizeBody(),timeoutMs:125000}))">在桌面端授权</button>
       <p v-if="!native?.local.ready" class="hint">在连接的 Mac 上确认授权,并按系统提示开启权限;系统可能要求重新打开 App。</p>
      </template>
      <label v-else>浏览器资料<select :value="native?.browser.profile" :disabled="busy" @change="run(()=>apiRequest(`/api/app/agents/${selected}/browser-profile`,{method:'PUT',body:{profile:($event.target as HTMLSelectElement).value}}))"><option value="persistent">此机器人的浏览器 · 保存登录</option><option value="temporary">临时浏览器 · 退出桌面端后清除</option></select></label>

+ 1 - 1
src/server/desktopEnvironments.ts

@@ -81,7 +81,7 @@ export class DesktopEnvironments {
  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)},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
- state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{name:info.name,platform:info.platform}:null,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)},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
+ state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:info.name,platform:info.platform}:null,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)},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
  private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||this.toolMode(owner,a)!==mode)throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}
  private invalidate(s:HostState){for(const p of s.pending.values()){if(p.sent&&['input','browser'].includes(String(p.command.operation)))s.paused.add(String(p.command.resource));clearTimeout(p.timer);p.reject(new HttpError(409,'桌面连接已改变;执行结果未确认,不会重试。','desktop_disconnected'))}s.pending.clear();s.frames.clear();for(const [key,m] of s.manual){s.paused.add(key);this.store.remove('_system','computer-control',m.id)}s.manual.clear()}
  close(){this.closed=true;for(const s of this.sessions.values())this.invalidate(s)}

+ 12 - 1
src/shared/desktopEnvironment.ts

@@ -1,10 +1,21 @@
 import type {WorkspaceAgent} from './workspace.js'
+export interface DesktopHostStatus {
+  id:string
+  name:string
+  platform:string
+  online:boolean
+  local:{supported:boolean;authorized:boolean;screen:boolean;accessibility:boolean;ready:boolean}
+  browser:{available:boolean}
+}
 export interface DesktopEnvironmentState {
  online:boolean
- host:{name:string;platform:string}|null
+ /** The host this agent resolves to; id is 'local' or a paired desktop-host id. */
+ host:{id:string;name:string;platform:string}|null
  local:{supported:boolean;authorized:boolean;screen:boolean;accessibility:boolean;ready:boolean}
  browser:{available:boolean;profile:'persistent'|'temporary'}
  selected:'local'|'browser'|null
  agent:WorkspaceAgent
+ /** Every connected desktop host, for the machine picker. */
+ hosts:DesktopHostStatus[]
 }
 export interface BotBrowserState {open:boolean;profile:string;tabs:{id:string;title:string;url:string;active:boolean}[]}

+ 42 - 0
tests/e2e/desktop-hosts.spec.ts

@@ -36,3 +36,45 @@ test('registers a desktop host, downloads its private config and disables it', a
   await expect(panel).toContainText('已停用')
   expect((await (await page.request.get('/api/app/admin/desktop-hosts')).json()).hosts.find((h: any) => h.id === configuration.hostId).enabled).toBe(false)
 })
+
+test('binds an agent to a connected desktop host from its chat panel', async ({ page }, testInfo) => {
+  await page.goto('/conversations')
+  await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
+  await page.getByRole('textbox', { name: '密码', exact: true }).fill('fixture-pass')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
+  await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '我的设置' })
+  await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
+  await dialog.locator('details.desktop-host-settings summary').click()
+  const panel = dialog.getByRole('region', { name: '桌面主机设置' })
+  await panel.getByRole('textbox', { name: '主机名称', exact: true }).fill('客厅 Mac mini')
+  await panel.getByRole('button', { name: '注册主机', exact: true }).click()
+  await expect(panel).toContainText('待连接')
+  const downloaded = page.waitForEvent('download')
+  await panel.getByRole('button', { name: '下载主机配置', exact: true }).click()
+  const stream = await (await downloaded).createReadStream(); const chunks: Buffer[] = []
+  for await (const chunk of stream!) chunks.push(Buffer.from(chunk))
+  const configuration = JSON.parse(Buffer.concat(chunks).toString())
+  const hostInfo = { id: crypto.randomUUID(), name: '客厅 Mac mini', platform: 'darwin', screen: true, accessibility: true, approved: [] }
+  const exchange = () => page.request.post(`/api/desktop-host/v1/${configuration.hostId}/exchange`, {
+    headers: { Authorization: `Bearer ${configuration.token}`, 'x-desktop-host-protocol': '1' },
+    data: { host: hostInfo, results: [] },
+  })
+  expect((await (await exchange()).json()).commands).toEqual([])
+  const seed = await (await page.request.post('/__test/computer?off=1', { data: {} })).json()
+  await page.goto(`/conversations/${seed.conversationId}`)
+  await page.getByRole('button', { name: '电脑与定时任务', exact: true }).click()
+  const dock = page.getByRole('complementary', { name: '机器人电脑面板' })
+  const localTab = dock.getByRole('tab', { name: '本机', exact: true })
+  await localTab.click()
+  await expect(localTab).toHaveAttribute('aria-selected', 'true')
+  const selector = dock.getByRole('combobox', { name: '操作的电脑', exact: true })
+  await expect(selector).toBeVisible()
+  await expect(selector.locator('option', { hasText: '客厅 Mac mini' })).toHaveCount(1)
+  await selector.scrollIntoViewIfNeeded()
+  await page.screenshot({ path: testInfo.outputPath('desktop-host-binding.png') })
+  await selector.selectOption(configuration.hostId)
+  const agents = (await (await page.request.get('/api/app/agents')).json()).agents
+  expect(agents.find((a: any) => a.id === seed.agentId)).toMatchObject({ computer: 'local', desktopHost: configuration.hostId })
+})