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

feat(envs): 机器人环境改为并列开关模型(envs)

WorkspaceAgent 新增 envs{vm,cloud,desktop,browser};agentEnvs 从旧
computer 单选派生(auto→全开,显式选择保留),deriveComputer 反向
投影保持 /api/app/agents 字段兼容。computer-selection 接受 envs 写入
(沿用在途/接管互斥),旧单值写入不变;envs 机器人的原生 computer
写入只拨动 VM 一档(本地虚拟机启停/Compose 绑定)。
Samien 1 месяц назад
Родитель
Сommit
af2a0a54cc
4 измененных файлов с 107 добавлено и 9 удалено
  1. 17 8
      src/server/grokCloud.ts
  2. 16 1
      src/server/workspaceStore.ts
  3. 19 0
      src/shared/workspace.ts
  4. 55 0
      tests/server/agentEnvs.test.ts

+ 17 - 8
src/server/grokCloud.ts

@@ -11,7 +11,7 @@ import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 import type {SharedComputers} from './sharedComputers.js'
-import type {WorkspaceAgent} from '../shared/workspace.js'
+import {agentEnvs,deriveComputer,type WorkspaceAgent} from '../shared/workspace.js'
 import type {ComputerControlStatus,ComputerFrame} from '../shared/computerControl.js'
 
 interface Descriptor {gatewayUrl:string;gatewayToken:string;networkToken:string;execDaemonUrl:string;execDaemonAuthToken:string}
@@ -187,15 +187,24 @@ 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']),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)
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.union([
+        z.object({envs:z.object({vm:z.boolean().optional(),cloud:z.boolean().optional(),desktop:z.boolean().optional(),browser:z.boolean().optional()}).strict(),desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional()}).strict(),
+        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)
+      const legacy='computer' in body
+      const legacyBody=legacy?body:undefined
+      const envsBody=legacy?undefined:body.envs
+      const current=agentEnvs(agent)
+      const next=envsBody?{vm:envsBody.vm===true,cloud:envsBody.cloud===true,desktop:envsBody.desktop===true,browser:envsBody.browser===true}:undefined
+      const targetComputer=next?deriveComputer(next):legacyBody!.computer
       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'
-      const allowHostEnvironment=(body.computer==='vm'&&vmExecution==='profile')||(['vm','cloud'].includes(body.computer)&&(body.allowHostEnvironment??(body.vmExecution==='worker'?false:agent.allowHostEnvironment)??false))
-      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(legacyBody&&legacyBody.vmExecution==='profile'&&legacyBody.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
+      const vmExecution=targetComputer==='vm'?(next?(agent.vmExecution??'worker'):(legacyBody!.vmExecution??(legacyBody!.allowHostEnvironment===false?'worker':agent.vmExecution)??'worker')):'worker'
+      const allowHostEnvironment=next?false:((legacyBody!.computer==='vm'&&vmExecution==='profile')||(['vm','cloud'].includes(legacyBody!.computer)&&(legacyBody!.allowHostEnvironment??(legacyBody!.vmExecution==='worker'?false:agent.allowHostEnvironment)??false)))
+      if(legacyBody&&legacyBody.computer==='vm'&&allowHostEnvironment)this.nodes.targetForAgent(owner,{...agent,execution:'computer',computer:'vm',allowHostEnvironment:true,vmExecution})
+      const enteringCloud=(next?next.cloud:legacyBody!.computer==='cloud')&&!current.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,...(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)})
+      this.store.atomic(()=>{const execution=targetComputer==='vm'?'computer':targetComputer==='auto'?(agent.execution??'profile'):'profile';if(execution!=='computer'&&agent.computerEnvironmentId)this.shared.detachLocalVm(owner,agent);this.store.updateAgent(owner,agent.id,{...(next?{envs:next}:{computer:legacyBody!.computer}),...(body.desktopHost!==undefined?{desktopHost:body.desktopHost}:{}),execution,allowHostEnvironment,vmExecution});const updated=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,updated);this.store.put(owner,'agent',agent.id,updated)})
       if(enteringCloud)await this.connect(owner)
       ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
     })

+ 16 - 1
src/server/workspaceStore.ts

@@ -9,7 +9,7 @@ import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import { notificationPlainText } from './notificationText.js'
-import {supportsHostEnvironment} from '../shared/workspace.js'
+import {supportsHostEnvironment,deriveComputer} from '../shared/workspace.js'
 import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '../shared/workspaceLifecycle.js'
 import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
@@ -35,6 +35,7 @@ const avatar = z
     '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
   )
 const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
+const envsInput = z.object({ vm: z.boolean().optional(), cloud: z.boolean().optional(), desktop: z.boolean().optional(), browser: z.boolean().optional() }).strict()
 export const agentInput = z
   .object({
     name,
@@ -42,6 +43,7 @@ export const agentInput = z
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    envs:envsInput.optional(),
     desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional(),
     allowHostEnvironment:z.boolean().default(false),
     vmExecution:z.enum(['worker','profile']).optional(),
@@ -61,6 +63,7 @@ export const agentPatch = z
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
     computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
+    envs:envsInput.optional(),
     desktopHost:z.union([z.literal('local'),z.string().uuid(),z.null()]).optional(),
     allowHostEnvironment:z.boolean().optional(),
     vmExecution:z.enum(['worker','profile']).optional(),
@@ -446,6 +449,18 @@ export class WorkspaceStore {
     if (patch.desktopHost === null) patch.desktopHost = undefined
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      // Parallel-environment agents keep their set; a raw computer write only
+      // toggles the VM leg (local-vm enable/disable and Compose bindings).
+      if(patch.computer!==undefined&&agent.envs){
+        const envs={...agent.envs}
+        if(patch.computer==='vm')envs.vm=true
+        else if(patch.computer==='off'||patch.computer==='auto')envs.vm=false
+        patch.envs=envs
+      }
+      if(patch.envs){
+        patch.computer=deriveComputer({vm:patch.envs.vm===true,cloud:patch.envs.cloud===true,desktop:patch.envs.desktop===true,browser:patch.envs.browser===true})
+        patch.execution=patch.computer==='vm'?'computer':'profile'
+      }
       const desktopHostChanged = patch.desktopHost !== undefined && patch.desktopHost !== (agent.desktopHost ?? null)
       if(patch.vmExecution==='profile'&&patch.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
       if(agent.vmExecution==='profile'){

+ 19 - 0
src/shared/workspace.ts

@@ -13,6 +13,8 @@ export interface WorkspaceAgent {
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
   computer?: 'auto' | 'cloud' | 'vm' | 'local' | 'browser' | 'off'
+  /** Parallel environment toggles. Absent on older records: derive from computer. */
+  envs?: { vm?: boolean; cloud?: boolean; desktop?: boolean; browser?: boolean }
   /** Bound desktop host: 'local' (loopback App) or a paired desktop-host id. */
   desktopHost?: string | null
   allowHostEnvironment?: boolean
@@ -38,6 +40,23 @@ export interface WorkspaceAgent {
   createdAt: number
   updatedAt: number
 }
+export interface AgentEnvs { vm:boolean; cloud:boolean; desktop:boolean; browser:boolean }
+/** Normalized parallel environments; legacy single-choice agents derive here. */
+export function agentEnvs(agent: Pick<WorkspaceAgent,'computer'|'envs'>): AgentEnvs {
+  if(agent.envs)return {vm:agent.envs.vm===true,cloud:agent.envs.cloud===true,desktop:agent.envs.desktop===true,browser:agent.envs.browser===true}
+  switch(agent.computer){
+    case 'vm':return {vm:true,cloud:false,desktop:false,browser:false}
+    case 'cloud':return {vm:false,cloud:true,desktop:false,browser:false}
+    case 'local':return {vm:false,cloud:false,desktop:true,browser:false}
+    case 'browser':return {vm:false,cloud:false,desktop:false,browser:true}
+    case 'auto':return {vm:true,cloud:true,desktop:true,browser:false}
+    default:return {vm:false,cloud:false,desktop:false,browser:false}
+  }
+}
+/** Single-choice projection kept for older clients reading agent.computer. */
+export function deriveComputer(envs: Pick<AgentEnvs,'vm'|'cloud'|'desktop'|'browser'>): 'vm'|'cloud'|'local'|'browser'|'off' {
+  return envs.vm?'vm':envs.cloud?'cloud':envs.desktop?'local':envs.browser?'browser':'off'
+}
 export function supportsHostEnvironment(agent: Pick<WorkspaceAgent,'computer'|'execution'>):boolean {
   return agent.computer==='vm'||agent.computer==='cloud'||(!agent.computer&&agent.execution==='computer')
 }

+ 55 - 0
tests/server/agentEnvs.test.ts

@@ -0,0 +1,55 @@
+// @vitest-environment node
+import {beforeEach,afterEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {join} from 'node:path'
+import {tmpdir} from 'node:os'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {loadServerConfig} from '../../src/server/config'
+import {GrokCloud} from '../../src/server/grokCloud'
+import {agentEnvs,deriveComputer} from '../../src/shared/workspace'
+
+let home:string,store:WorkspaceStore,nodes:WorkspaceNodes
+const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),requireAdmin:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:'admin'}),isUserActive:()=>true,pushAuthorizationVersion:()=>1} as any
+beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'agent-envs-'));store=new WorkspaceStore(home);nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)})
+afterEach(()=>{nodes.close();store.close();rmSync(home,{recursive:true,force:true})})
+const app=(cloud:GrokCloud)=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(cloud.router().routes());return a.callback()}
+
+it('derives parallel environments from legacy single choices and auto',()=>{
+  expect(agentEnvs({computer:'vm'})).toEqual({vm:true,cloud:false,desktop:false,browser:false})
+  expect(agentEnvs({computer:'cloud'})).toEqual({vm:false,cloud:true,desktop:false,browser:false})
+  expect(agentEnvs({computer:'local'})).toEqual({vm:false,cloud:false,desktop:true,browser:false})
+  expect(agentEnvs({computer:'auto'})).toEqual({vm:true,cloud:true,desktop:true,browser:false})
+  expect(agentEnvs({computer:'off'})).toEqual({vm:false,cloud:false,desktop:false,browser:false})
+  expect(agentEnvs({envs:{cloud:true,desktop:true}})).toEqual({vm:false,cloud:true,desktop:true,browser:false})
+  expect(deriveComputer({vm:true,cloud:true,desktop:true,browser:false})).toBe('vm')
+  expect(deriveComputer({cloud:true,desktop:true})).toBe('cloud')
+  expect(deriveComputer({desktop:true})).toBe('local')
+  expect(deriveComputer({})).toBe('off')
+})
+
+it('writes envs through computer-selection and projects computer/execution for older clients',async()=>{
+  const cloud=new GrokCloud(store,auth,nodes,{assertLocalVmIdle:()=>{}} as any,vi.fn(async()=>new Response('{}')))
+  const agent=store.createAgent('owner',{name:'并行机器人',profile:'default',computer:'auto'})
+  const path=`/api/app/agents/${agent.id}/computer-selection`
+  const first=await request(app(cloud)).put(path).send({envs:{vm:true,cloud:true,desktop:true}}).expect(200)
+  expect(first.body.agent).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'computer'})
+  const cleared=await request(app(cloud)).put(path).send({envs:{}}).expect(200)
+  expect(cleared.body.agent).toMatchObject({envs:{},computer:'off',execution:'profile'})
+  const desktopOnly=await request(app(cloud)).put(path).send({envs:{desktop:true}}).expect(200)
+  expect(desktopOnly.body.agent).toMatchObject({envs:{desktop:true},computer:'local',execution:'profile',allowHostEnvironment:false})
+  await request(app(cloud)).put(path).send({envs:{vm:true},desktopHost:'local'}).expect(200)
+  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true},desktopHost:'local',computer:'vm',execution:'computer'})
+})
+
+it('raw computer writes on an envs agent only toggle the VM leg',async()=>{
+  const agent=store.createAgent('owner',{name:'混合',profile:'default',computer:'local'})
+  store.updateAgent('owner',agent.id,{envs:{vm:true,cloud:true,desktop:true}})
+  store.updateAgent('owner',agent.id,{computer:'off'})
+  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:false,cloud:true,desktop:true},computer:'cloud',execution:'profile'})
+  store.updateAgent('owner',agent.id,{computer:'vm'})
+  expect(store.require<any>('owner','agent',agent.id)).toMatchObject({envs:{vm:true,cloud:true,desktop:true},computer:'vm',execution:'computer'})
+})