Просмотр исходного кода

feat(desktop-host): 设置面板支持注册桌面主机并下发一次性配置

我的设置 → Hermes 连接 → 桌面主机:注册(名称)、查看在线状态、
停用;注册后仅本次可下载 desktop-host.json。
computer-selection 路由接受 desktopHost 绑定('local'/主机 UUID/清除),
切换绑定沿用既有在途任务与接管互斥检查。
Samien 1 месяц назад
Родитель
Сommit
29ab713fa5

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "release:verify": "node scripts/verify-release.mjs",
     "verify:lifecycle": "node scripts/verify-lifecycle.mjs",
     "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts",
+    "test:e2e:desktop-hosts": "npm run build && playwright test -c playwright.desktop-hosts.config.ts",
     "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts",
     "desktop:build": "npm run build && node scripts/build-desktop.mjs",
     "desktop:dev": "npm run desktop:build && electron .",

+ 1 - 1
playwright.config.ts

@@ -6,7 +6,7 @@ const upstreamPort = Number(process.env.FAKE_HERMES_PORT || 19119)
 export default defineConfig({
   testDir: './tests/e2e',
   // These suites own different fixtures and run with their dedicated configurations.
-  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts'],
+  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts', '**/desktop-hosts.spec.ts'],
   timeout: 30_000,
   fullyParallel: false,
   workers: 1,

+ 22 - 0
playwright.desktop-hosts.config.ts

@@ -0,0 +1,22 @@
+import { defineConfig, devices } from '@playwright/test'
+export default defineConfig({
+  testDir: './tests/e2e',
+  testMatch: 'desktop-hosts.spec.ts',
+  workers: 1,
+  timeout: 45_000,
+  reporter: 'list',
+  use: {
+    baseURL: 'http://127.0.0.1:18809',
+    viewport: { width: 1280, height: 800 },
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+  },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+  webServer: {
+    command:
+      'WORKSPACE_FIXTURE_PORT=18809 WORKSPACE_FIXTURE_UPSTREAM_PORT=19129 node --import tsx tests/fixtures/workspace-server.ts',
+    url: 'http://127.0.0.1:18809/healthz',
+    reuseExistingServer: false,
+    timeout: 20_000,
+  },
+})

+ 95 - 0
src/client/components/app/DesktopHostSettingsPanel.vue

@@ -0,0 +1,95 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { DesktopHostConfiguration, DesktopHostSummary } from '@shared/desktopHost'
+
+const hosts = ref<DesktopHostSummary[]>([])
+const busy = ref(false), error = ref(''), notice = ref('')
+const name = ref('')
+const serverURL = ref(window.location.origin)
+const insecure = ref(false)
+const configuration = ref<DesktopHostConfiguration>()
+
+async function refresh() {
+  const data = await apiRequest<{hosts:DesktopHostSummary[]}>('/api/app/admin/desktop-hosts')
+  hosts.value = data.hosts
+}
+async function action(work:()=>Promise<void>) {
+  if(busy.value) return
+  error.value = ''; notice.value = ''; busy.value = true
+  try { await work() } catch(cause) { error.value = cause instanceof Error ? cause.message : '桌面主机操作失败' }
+  finally { busy.value = false }
+}
+async function register() {
+  await action(async()=>{
+    const web = new URL(serverURL.value)
+    if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
+    if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程主机请使用 HTTPS,或勾选可信局域网 HTTP')
+    const result = await apiRequest<{host:DesktopHostSummary;token:string}>('/api/app/admin/desktop-hosts', {method:'POST',body:{name:name.value.trim()}})
+    configuration.value = {protocol:1,serverURL:web.origin,hostId:result.host.id,token:result.token,...(insecure.value?{allowInsecureLan:true}:{})}
+    notice.value = '主机已注册,请下载配置并在那台 Mac 的夭夭 App 中导入。'
+    await refresh()
+  })
+}
+function download() {
+  if(!configuration.value) return
+  const url = URL.createObjectURL(new Blob([JSON.stringify(configuration.value,null,2)+'\n'],{type:'application/json'}))
+  const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'desktop-host.json'; anchor.click()
+  setTimeout(()=>URL.revokeObjectURL(url),1000)
+}
+function disable(host:DesktopHostSummary) {
+  void action(async()=>{
+    await apiRequest(`/api/app/admin/desktop-hosts/${host.id}`,{method:'DELETE',body:{}})
+    if(configuration.value?.hostId===host.id)configuration.value=undefined
+    await refresh(); notice.value = `已停用 ${host.name}`
+  })
+}
+onMounted(()=>{void action(refresh)})
+</script>
+
+<template>
+  <section class="desktop-host-panel" aria-label="桌面主机设置">
+    <p>在其他 Mac 上安装夭夭 App 并导入主机配置后,那台电脑会主动连接本服务,机器人即可操作它的真实桌面。每台主机单独授权,配置凭据仅注册时下发一次。</p>
+    <div class="host-toolbar"><strong>已注册主机</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
+    <ul v-if="hosts.length" class="host-list">
+      <li v-for="host in hosts" :key="host.id">
+        <div><strong>{{ host.name }}</strong><span>{{ !host.enabled ? '已停用' : host.online ? `已连接 · ${host.hostName || host.platform || 'Mac'}` : '待连接' }}</span></div>
+        <button v-if="host.enabled" type="button" :disabled="busy" :aria-label="`停用 ${host.name}`" @click="disable(host)">停用</button>
+      </li>
+    </ul>
+    <p v-else>尚未注册桌面主机。</p>
+    <form v-if="!configuration" @submit.prevent="register">
+      <label>主机名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="书房 iMac" :disabled="busy"></label>
+      <small>用于在电脑面板中区分各台机器。</small>
+      <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"></label>
+      <small>必须能从那台 Mac 访问。</small>
+      <label class="host-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
+      <button class="solid-button" :disabled="busy || !name.trim()">注册主机</button>
+    </form>
+    <div v-else class="host-setup">
+      <button class="solid-button" type="button" @click="download">下载主机配置</button>
+      <p>配置包含这台主机的连接凭据,仅本次可下载。在那台 Mac 上打开夭夭 App,通过菜单「桌面主机 → 导入主机配置…」选择下载的 desktop-host.json。</p>
+      <small>配置丢失后可停用主机并重新注册;停用立即断开那台电脑。</small>
+    </div>
+    <p v-if="error" class="host-error" role="alert">{{ error }}</p>
+    <p v-if="notice" class="host-notice" role="status">{{ notice }}</p>
+  </section>
+</template>
+
+<style scoped>
+.desktop-host-panel { display: grid; gap: 14px; min-width: 0; font-size: 13px; }
+.desktop-host-panel p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
+.host-toolbar,.host-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.desktop-host-panel button { min-height: 36px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
+.desktop-host-panel button:disabled { opacity: .5; cursor: default; }
+.host-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.host-list li { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
+.host-list li div { display: grid; gap: 5px; overflow-wrap: anywhere; }
+.host-list span,.desktop-host-panel small { color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
+.desktop-host-panel form,.desktop-host-panel label,.host-setup { display: grid; gap: 8px; min-width: 0; }
+.desktop-host-panel form { gap: 14px; }
+.desktop-host-panel input:not([type=checkbox]) { box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
+.desktop-host-panel .host-check { display: flex; align-items: center; }
+.desktop-host-panel .host-error { color: var(--danger); }
+.desktop-host-panel :is(input,select,button):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+</style>

+ 6 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -3,6 +3,7 @@ import HermesBridgePanel from '@/components/app/HermesBridgePanel.vue'
 import { computed, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
+import DesktopHostSettingsPanel from './DesktopHostSettingsPanel.vue'
 import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import {
   createUser,
@@ -46,6 +47,7 @@ const props = withDefaults(defineProps<{
 const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
 
 const runnerSettingsOpen = ref(false)
+const desktopHostSettingsOpen = ref(false)
 const assignedProfiles = ref<string[]>([])
 const userAssignments = ref<Record<string, string[]>>({})
 const users = ref<ManagedUser[]>([])
@@ -514,6 +516,10 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <summary>执行节点</summary>
         <RunnerSettingsPanel v-if="runnerSettingsOpen" />
       </details>
+      <details class="desktop-host-settings" @toggle="desktopHostSettingsOpen = ($event.target as HTMLDetailsElement).open">
+        <summary>桌面主机</summary>
+        <DesktopHostSettingsPanel v-if="desktopHostSettingsOpen" />
+      </details>
       <div class="block network-access-block">
         <h3>外网访问地址</h3>
         <p class="network-description">允许通过指定域名或公网 IP 访问 15300。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>

+ 2 - 2
src/server/grokCloud.ts

@@ -187,7 +187,7 @@ export class GrokCloud {
       if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
     })
     router.put('/api/app/agents/:id/computer-selection',async ctx=>{
-      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','local','browser','off']),allowHostEnvironment:z.boolean().optional(),vmExecution:z.enum(['worker','profile']).optional()}).strict(),(ctx.request as any).body)
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','local','browser','off']),desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional(),allowHostEnvironment:z.boolean().optional(),vmExecution:z.enum(['worker','profile']).optional()}).strict(),(ctx.request as any).body)
       this.beforeSelection?.(owner,agent.id);this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
       if(body.vmExecution==='profile'&&body.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
       const vmExecution=body.computer==='vm'?(body.vmExecution??(body.allowHostEnvironment===false?'worker':agent.vmExecution)??'worker'):'worker'
@@ -195,7 +195,7 @@ export class GrokCloud {
       if(body.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true,vmExecution})
       const enteringCloud=body.computer==='cloud'&&agent.computer!=='cloud'
       if(enteringCloud)await this.requireAvailable(owner,agent,this.nodes.target(owner,agent.nodeId))
-      this.store.atomic(()=>{const execution=body.computer==='vm'?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution,allowHostEnvironment,vmExecution});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
+      this.store.atomic(()=>{const execution=body.computer==='vm'?'computer':body.computer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{computer:body.computer,execution,allowHostEnvironment,vmExecution,...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{})});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
       if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })

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

@@ -0,0 +1,38 @@
+import { test, expect } from '@playwright/test'
+
+test('registers a desktop host, downloads its private config and disables it', 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 expect(panel).toContainText('尚未注册桌面主机')
+  await panel.getByRole('textbox', { name: '主机名称', exact: true }).fill('书房 iMac')
+  await page.screenshot({ path: testInfo.outputPath('desktop-host-registration.png') })
+  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 download = await downloaded
+  expect(download.suggestedFilename()).toBe('desktop-host.json')
+  const stream = await download.createReadStream(); const chunks: Buffer[] = []
+  for await (const chunk of stream!) chunks.push(Buffer.from(chunk))
+  const configuration = JSON.parse(Buffer.concat(chunks).toString())
+  expect(configuration).toMatchObject({ protocol: 1, serverURL: expect.stringMatching(/^https?:\/\//) })
+  expect(configuration.hostId).toMatch(/^[0-9a-f-]{36}$/)
+  expect(configuration.token.length).toBeGreaterThan(32)
+  const result = await (await page.request.get('/api/app/admin/desktop-hosts')).json()
+  expect(JSON.stringify(result)).not.toContain(configuration.token)
+  expect(JSON.stringify(result)).not.toContain('tokenHash')
+  await page.setViewportSize({ width: 390, height: 844 })
+  await panel.getByRole('button', { name: '停用 书房 iMac' }).scrollIntoViewIfNeeded()
+  await page.screenshot({ path: testInfo.outputPath('desktop-host-registered-mobile.png') })
+  await panel.getByRole('button', { name: '停用 书房 iMac' }).click()
+  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)
+})