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

fix(desktop): 消息带设备来源,「本机」按发消息电脑解析

用户消息携带 local/主机编号;提示与设置用可改名。网页无来源时仍默认服务器。
Samien 1 месяц назад
Родитель
Сommit
33a855d

+ 15 - 0
desktop/main.mjs

@@ -266,6 +266,21 @@ else {
       encrypt:value=>credentials.encrypt(value),
       decrypt:bytes=>credentials.decrypt(bytes),
       onState:message=>{log(`桌面主机: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
+    ipcMain.handle('desktop:device-host', async event => {
+      try {
+        const trusted = event.sender === window?.webContents && event.senderFrame === event.sender.mainFrame
+          && serviceOrigins().includes(new URL(event.senderFrame.url).origin)
+        if (!trusted || closing || quitting) throw new Error('不允许此页面读取设备身份')
+      } catch (error) {
+        if (error.message === '不允许此页面读取设备身份') throw error
+        throw new Error('不允许此页面读取设备身份')
+      }
+      if (remoteMode) {
+        const enrolled = await hostManager.read().catch(() => undefined)
+        return { deviceHost: enrolled?.hostId ?? null }
+      }
+      return { deviceHost: 'local' }
+    })
     async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'桌面主机操作未完成',detail:error.message})}}
     const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
     async function importDesktopHost(){

+ 2 - 0
desktop/preload.cjs

@@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
   openUpdates: () => ipcRenderer.invoke('desktop:updates'),
+  /** 'local' on the YaoYao server Mac; paired host uuid in remote mode; null if unknown. */
+  deviceHost: () => ipcRenderer.invoke('desktop:device-host'),
   openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),
   computerClosed: () => ipcRenderer.invoke('desktop-computer:closed'),
   onComputerClose: callback => {

+ 4 - 4
src/client/components/app/HostToolsPanel.vue

@@ -16,7 +16,7 @@ async function loadNames() {
   nameError.value = ''
   try {
     const value = await apiRequest<{localName?: string; hosts: {id:string; name:string; displayName?: string; enabled:boolean; hostName?: string}[]}>('/api/app/admin/desktop-hosts')
-    const rows = [{ id: 'local', label: '本机 · 夭夭所在的电脑', name: value.localName || '' }]
+    const rows = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
     for (const host of value.hosts) {
       if (!host.enabled) continue
       rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '' })
@@ -69,7 +69,7 @@ onMounted(() => { void load(); void loadNames() })
     <p>这些开关对所有机器人生效,不再按单个机器人勾选。关掉之后,新开或恢复的会话才会按新设置执行。</p>
     <form @submit.prevent="save" @change="changed">
       <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>夭夭正在运行的这台电脑。操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。查看本机只看它。</small></span></label>
+      <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。</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>
@@ -78,9 +78,9 @@ onMounted(() => { void load(); void loadNames() })
       <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>
     <div class="names">
-      <p>每台电脑可以改名。机器人按这个名字点名。本机留空时仍叫「本机」,而且写「本机」一定是夭夭所在的电脑。</p>
+      <p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,脚本机留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
       <p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
-      <label v-for="host in hosts" :key="host.id"><span>{{ host.label }}</span><input v-model="drafts[host.id]" maxlength="64" :placeholder="host.id === 'local' ? '本机' : host.label" :aria-label="`名字 ${host.label}`" /><button type="button" @click="saveName(host.id)">保存名字</button></label>
+      <label v-for="host in hosts" :key="host.id"><span>{{ host.label }}</span><input v-model="drafts[host.id]" maxlength="64" :placeholder="host.id === 'local' ? '服务器' : host.label" :aria-label="`名字 ${host.label}`" /><button type="button" @click="saveName(host.id)">保存名字</button></label>
       <p v-if="!hosts.length && !nameError">还没有可改名的电脑。</p>
     </div>
   </section>

+ 2 - 2
src/client/components/workspace/LocalVmChatPanel.vue

@@ -189,9 +189,9 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <p class="hint">电脑开关在 Bot 设置里,对所有机器人一起生效。</p>
     <div class="computer-options">
     <template v-if="showDesktop">
-      <p>服务器就是夭夭所在的这台电脑,操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。其他脚本机按名称点名,不会因为只有一台在线就自动跳过去。</p>
+      <p>服务器是夭夭所在的这台电脑;脚本机是另外连接的 Mac。两者都可改名。「本机」跟你当前发消息所在的电脑走:在服务器上聊就是服务器,在脚本机上聊就是那台脚本机。操作方式和云虚拟机一样:先看真实桌面,再点击、输入、按键或滚动。</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||'服务器')):('脚本机 · '+(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>

+ 1 - 0
src/client/env.d.ts

@@ -12,6 +12,7 @@ interface ImportMeta {
 interface Window {
   yaoyaoDesktop?: {
     openUpdates?(): Promise<void>
+    deviceHost?(): Promise<{ deviceHost: string | null }>
     openComputer(id: string): Promise<boolean>
     computerClosed(): Promise<void>
     onComputerClose(callback: () => void): () => void

+ 4 - 0
src/client/views/ConversationsView.vue

@@ -265,6 +265,8 @@ const selected = computed(() => (typeof route.params.id === 'string' ? route.par
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
 const computerOpen=ref(false),dockView=ref<'computer'|'inspector'>(),twoDesktops=ref(false),desktopAgent=ref<Agent>(),desktopBackend=ref<'desktop'|'cloud'|'vm'>(),desktopHost=ref('')
+let cachedDeviceHost:string|undefined|null=undefined
+async function resolveDeviceHost(){if(cachedDeviceHost!==undefined)return cachedDeviceHost??undefined;if(!window.yaoyaoDesktop?.deviceHost){cachedDeviceHost=null;return undefined};try{const r=await window.yaoyaoDesktop.deviceHost();cachedDeviceHost=r?.deviceHost??null;return cachedDeviceHost??undefined}catch{cachedDeviceHost=null;return undefined}}
 const creatingTask=ref(false)
 const CREATE_TASK_VALUE='__create_task__'
 function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
@@ -768,6 +770,7 @@ async function send() {
     pendingFingerprint = fingerprint
   }
   try {
+    const deviceHost = await resolveDeviceHost()
     const receipt = await apiRequest<WorkspaceSendReceipt>(`/api/app/conversations/${c.id}/messages`, {
       method: 'POST',
       body: {
@@ -778,6 +781,7 @@ async function send() {
         content: text.value,
         mentionIds: mentions.value,
         fileIds: files.value.map((f) => f.id),
+        ...(deviceHost ? { deviceHost } : {}),
       },
     })
     if (disposed || accountEpoch !== epoch || auth.user?.id !== owner) return true

+ 46 - 14
src/server/desktopEnvironments.ts

@@ -54,7 +54,7 @@ interface HostState {
 }
 const newHostState=(key:string,remote:boolean):HostState=>({key,remote,seen:0,pending:new Map(),queue:new Map(),manual:new Map(),paused:new Set(),frames:new Map(),generation:new Map(),notes:new Map(),activity:new Map()})
 export const DESKTOP_ENVIRONMENT_TOOLS=[
- {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看电脑真实桌面截图,返回截图尺寸、环境类型和电脑名称。操作前必须先看。可用 host 指定目标电脑;省略或写「本机」是夭夭所在的服务器。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用默认电脑'}},additionalProperties:false}},
+ {id:'desktop_environment_view',name:'desktop_environment_view',description:'查看电脑真实桌面截图,返回截图尺寸、环境类型和电脑名称。操作前必须先看。可用 host 指定目标电脑;省略或写「本机」时,优先对应用户当前发消息所在的那台电脑(有来源时),否则用默认绑定。',inputSchema:{type:'object',properties:{host:{type:'string',description:'可选,目标电脑的名称或编号;缺省用默认电脑'}},additionalProperties:false}},
  {id:'desktop_environment_action',name:'desktop_environment_action',description:'操作电脑桌面。先查看真实桌面,再使用 click、text、key、scroll 或 drag;坐标用截图像素。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{host:{type:'string'},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:{type:'string'},key:{type:'string'},modifiers:{type:'array',items:{enum:['ctrl','alt','shift','super']}},direction:{enum:['up','down','left','right']},amount:{type:'integer'}},required:['kind'],additionalProperties:false}},
  {id:'desktop_file_list',name:'desktop_file_list',description:'列出当前电脑允许范围内的目录内容,返回名称、类型、大小与修改时间。路径相对于用户主目录,可用 ~。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
  {id:'desktop_file_read',name:'desktop_file_read',description:'读取当前电脑上的一个文件,返回 base64 内容与大小。路径相对于用户主目录;超出允许范围会被拒绝。可用 host 指定目标电脑。',inputSchema:{type:'object',properties:{path:{type:'string'},host:{type:'string'}},required:['path'],additionalProperties:false}},
@@ -63,9 +63,9 @@ export const DESKTOP_ENVIRONMENT_TOOLS=[
 ]
 export const DESKTOP_FILE_TOOL_IDS=new Set(['desktop_file_list','desktop_file_read','desktop_file_write','desktop_shell'])
 /** Satellite Macs connected as script machines. */
-export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。电脑操作、文件读写和命令必须使用 desktop_*,并用 host 写明电脑名称。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。服务器本机、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const DESKTOP_ENVIRONMENT_RULES='当前环境是脚本机,也就是客户连接的 Mac。电脑操作、文件读写和命令必须使用 desktop_*;可用 host 写明电脑名称或编号。省略 host 或写「本机」时:若本轮用户消息带来源设备,就用那台电脑;否则用 Bot 的默认绑定。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。服务器、虚拟环境和云虚拟机都不是脚本机。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 /** Yaoyao host Mac — same look-then-act pattern as the cloud computer. */
-export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑操作、文件读写和命令必须使用 desktop_*;省略 host 或写「本机」只看这台电脑。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
+export const SERVER_COMPUTER_RULES='当前环境是服务器,也就是夭夭正在运行的这台电脑。电脑名称与设置里一致,可改名;点名用这个名字。电脑操作、文件读写和命令必须使用 desktop_*。省略 host 或写「本机」时:若本轮用户消息来自服务器,就用这台电脑;若来自某台脚本机,则「本机」指那台脚本机;没有来源时默认仍是这台服务器。先查看真实桌面,再使用 click、text、key、scroll 或 drag;页面变化后重新截图,不得猜测旧坐标。文件与 shell 在用户主目录范围内真实生效。脚本机、虚拟环境和云虚拟机都不是这台服务器本身。人工接管时等待交还。电脑断开时报告原因,不得宣称操作成功。'
 
 /** The loopback native transport is admitted only by serviceInstance's
  *  private capability; remote hosts are admitted by DesktopHostHub's bearer
@@ -85,11 +85,11 @@ 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}
- /** 本机 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. */
+ /** Default session: agent desktopHost binding, else server local.
+ *  「本机」 at tool time prefers the user message deviceHost when provided. */
  private computers(){try{const s=readHostTools(this.store.home);return {script:s.scriptMachine,server:s.serverComputer}}catch{return {script:false,server:false}}}
  private names(){try{return readComputerNames(this.store.home)}catch{return {}}}
- private label(s:HostState){const custom=this.names()[s.key];if(s.key==='local')return custom||'本机';return custom||s.info?.name||''}
+ private label(s:HostState){const custom=this.names()[s.key];if(s.key==='local')return custom||'服务器';return custom||s.info?.name||''}
  private hostOpen(s:HostState){const g=this.computers();return s.key==='local'?g.server:g.script}
  private sessionFor(agent:WorkspaceAgent):HostState|undefined{
   const g=this.computers()
@@ -100,18 +100,28 @@ export class DesktopEnvironments {
   if(!g.server)return undefined
   return this.sessions.get('local')
  }
- private namedHost(host:string):HostState{
+ /** Resolve an explicit host name/id. When host is 本机/local, prefer contextDeviceHost. */
+ private namedHost(host:string,contextDeviceHost?:string):HostState{
   const g=this.computers()
   if(host==='本机'||host==='local'){
-   if(!g.server)throw new HttpError(409,'全局设置未开放夭夭所在电脑的操作。','desktop_offline')
+   const contextual=this.contextSession(contextDeviceHost)
+   if(contextual)return contextual
+   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')
+   throw new HttpError(409,'服务器电脑未连接,请在这台电脑上打开夭夭。','desktop_offline')
   }
   if(!g.script)throw new HttpError(409,'全局设置未开放脚本机操作。','desktop_offline')
   const names=this.names();for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase())||(names[s.key]&&names[s.key].toLowerCase()===host.toLowerCase()))return s
   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
  }
+ /** Session for the user's messaging device when it is online and globally open. */
+ private contextSession(deviceHost?:string):HostState|undefined{
+  if(!deviceHost)return undefined
+  const s=this.sessions.get(deviceHost==='local'?'local':deviceHost)
+  if(!s||!this.hostOnline(s)||!this.hostOpen(s))return undefined
+  return s
+ }
  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). */
@@ -130,13 +140,35 @@ export class DesktopEnvironments {
   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)return this.namedHost(host)
+ private targetFor(agent:WorkspaceAgent,host:unknown,contextDeviceHost?:string):HostState{
+  if(typeof host==='string'&&host)return this.namedHost(host,contextDeviceHost)
+  const contextual=this.contextSession(contextDeviceHost)
+  if(contextual)return contextual
   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)&&this.hostOpen(s)).map(s=>s.key==='local'?(this.names()[s.key]?this.names()[s.key]+'(本机)':'本机'):(this.label(s)||s.key));return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;省略 host 或写「本机」时使用夭夭所在的这台电脑。`:''}
+ onlineHostsLine(owner:string,deviceHost?:string){
+  const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>{
+   const label=s.key==='local'?(this.names()[s.key]||'服务器'):(this.label(s)||s.key)
+   const kind=s.key==='local'?'服务器':'脚本机'
+   const mine=deviceHost&&(deviceHost==='local'?s.key==='local':s.key===deviceHost)
+   return mine?`${label}(${kind}·本机)`:`${label}(${kind})`
+  })
+  const tip=deviceHost
+   ?'省略 host 或写「本机」时,优先使用用户当前发消息所在的那台电脑;也可用设置里的电脑名称指定其它电脑。'
+   :'省略 host 或写「本机」时,没有用户设备来源则默认服务器,否则用 Bot 绑定;也可用设置里的电脑名称指定。'
+  return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;${tip}`:''
+ }
+ deviceContextLine(owner:string,deviceHost:string){
+  const s=this.contextSession(deviceHost)
+  if(!s){
+   const named=this.names()[deviceHost==='local'?'local':deviceHost]
+   return `本轮用户消息来源设备:${named||deviceHost}(当前未在线或未开放)。`
+  }
+  const kind=s.key==='local'?'服务器':'脚本机'
+  return `本轮用户消息来自${kind}「${this.label(s)}」。名字与设置里的电脑名称相同;对本轮而言,「本机」指这台电脑。`
+ }
  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:this.label(s),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))}
@@ -199,11 +231,11 @@ export class DesktopEnvironments {
   }
   const mode=this.toolMode(owner,a);if(!mode)throw new HttpError(409,'当前未选择桌面环境','desktop_unselected');this.context(this.sessionFor(a),owner,a,mode).check();await requireTeamToolBridge(target,a.profile)
  }
- async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined){
+ async call(owner:string,agentId:string,id:string,input:unknown,signal:AbortSignal,assertActive:()=>void,expected:Mode,epoch:string|Record<string,string>|undefined,deviceHost?:string){
   if(id==='desktop_browser'||expected==='browser')throw new HttpError(404,'当前环境不支持这个工具','tool_not_found')
   const a=this.agent(owner,agentId)
   const {host,...arguments_}=typeof input==='object'&&input?input as Record<string,unknown>:{}
-  const s=this.targetFor(a,host)
+  const s=this.targetFor(a,host,deviceHost)
   const c=this.context(s,owner,a,expected)
   const pinned=typeof epoch==='string'?(s===this.sessionFor(a)?epoch:undefined):epoch?.[s.key]
   const check=()=>{signal.throwIfAborted();assertActive();c.check();if(pinned!==undefined&&s.info?.id!==pinned)throw new HttpError(410,'桌面连接已改变','desktop_disconnected')};check()

+ 13 - 4
src/server/workspaceRuntime.ts

@@ -84,6 +84,10 @@ interface LiveTurn {
   taskId: string
   done(error?: Error): void
 }
+const deviceHostId = z.union([
+  z.literal('local'),
+  z.string().uuid(),
+])
 export const sendInput = z
   .object({
     requestId: z.string().uuid(),
@@ -95,6 +99,8 @@ export const sendInput = z
     projectId: z.string().min(1).max(100).nullable().optional(),
     mentionIds: z.array(z.string().uuid()).max(8).default([]),
     fileIds: z.array(z.string().uuid()).max(8).default([]),
+    /** Desktop host the client is messaging from: 'local' or paired host uuid. */
+    deviceHost: deviceHostId.optional(),
   })
   .strict()
   .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
@@ -232,6 +238,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         peerMessageId: source?.peerMessageId,
         ...(source?.taskReference ? {taskReference:source.taskReference} : {}),
         ...(source ? { agentId: source.agentId, agentName: this.store.require<Agent>(owner, 'agent', source.agentId).name } : {}),
+        ...(!source && body.deviceHost ? { deviceHost: body.deviceHost } : {}),
         content: body.content.trim(),
         reasoning: '',
         runId,
@@ -262,6 +269,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationId,
         conversationTaskId,
         messageId: message.id,
+        ...(!source && body.deviceHost ? { deviceHost: body.deviceHost } : {}),
         mentionIds: mentions,
         activeAgentId: c.administratorId,
         status: 'queued',
@@ -739,7 +747,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
             catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(dispatchedVm?VM_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>(envTools.view&&['desktop_environment_view','desktop_environment_action'].includes(t.id))||(envTools.file&&DESKTOP_FILE_TOOL_IDS.has(t.id))):[]),...(pluginLease?.catalog()??[])],
-            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,'local',desktopEpochs):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
+            call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,'local',desktopEpochs,this.store.require<Run>(owner,'run',run.runId).deviceHost??this.store.get<Message>(owner,'message',run.messageId)?.deviceHost):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):toolId.startsWith('computer_')&&dispatchedVm?callVm(toolId,args):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
@@ -769,7 +777,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
         const globals = globalComputers(this.store.home)
         const eligible = !agent.remoteAgentId && !agent.temporaryGoalId && !agent.archived
-        const open = [eligible && globals.scriptMachine ? '脚本机' : '', eligible && globals.serverComputer ? '服务器(本机)' : '', eligible && globals.vm && agent.envs ? '虚拟环境' : '', eligible && globals.cloud ? '云虚拟机' : ''].filter(Boolean)
+        const open = [eligible && globals.scriptMachine ? '脚本机' : '', eligible && globals.serverComputer ? '服务器' : '', eligible && globals.vm && agent.envs ? '虚拟环境' : '', eligible && globals.cloud ? '云虚拟机' : ''].filter(Boolean)
         const hostNames = this.desktopEnvironments?.hostStates(owner).map(host => host.name).filter(Boolean) ?? []
         const prior = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, run.conversationTaskId)
           .filter(message => message.id !== resultMessage.id && message.seq <= run.triggerSeq)
@@ -804,8 +812,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           root.projectId ? `当前项目 ID:${root.projectId}。只能使用当前项目记忆。` : '',
           `本次来源消息 ID:${run.messageId};当前会话 ID:${c.id}。`,
           plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
-          desktop && hit.server && globals.serverComputer ? SERVER_COMPUTER_RULES+(this.desktopEnvironments?.onlineHostsLine(owner)??'') : '',
-          desktop && hit.script && globals.scriptMachine ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner)??'') : '',
+          desktop && hit.server && globals.serverComputer ? SERVER_COMPUTER_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,trigger.deviceHost)??'') : '',
+          desktop && hit.script && globals.scriptMachine ? DESKTOP_ENVIRONMENT_RULES+(this.desktopEnvironments?.onlineHostsLine(owner,trigger.deviceHost)??'') : '',
+          desktop && trigger.deviceHost ? (this.desktopEnvironments?.deviceContextLine(owner,trigger.deviceHost)??'') : '',
           cloud && hit.cloud ? grokComputerRules() : '',
           dispatchedVm && hit.vm ? VM_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,

+ 4 - 0
src/shared/workspace.ts

@@ -178,6 +178,8 @@ export interface WorkspaceMessage {
   peerMessageId?: string
   revision?: number
   execution?:'profile'|'computer'
+  /** Desktop host the user was on when sending: 'local' (YaoYao server) or paired host uuid. */
+  deviceHost?: string
   id: string
   conversationId: string
   /** User-visible task scope. `taskId` below remains the scheduler turn ID. */
@@ -234,6 +236,8 @@ export interface WorkspaceRun {
   collaborationChainId?: string
   priority?: boolean
   discussion?: { memberIds: string[]; rounds: number }
+  /** Copied from the triggering user message when present. */
+  deviceHost?: string
   id: string
   conversationId: string
   conversationTaskId?: string

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

@@ -190,3 +190,27 @@ it('expired control remains paused until a new takeover is explicitly returned',
 it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'local',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'local',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
 it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
 it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(409);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
+
+it('resolves 本机 to the messaging device when deviceHost is a script machine',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local'})
+ drive()
+ driveRemote(HOST_A,{...host,name:'客厅 Mac'})
+ const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
+ expect(JSON.parse(view.content[1].text).host).toBe('客厅 Mac')
+ const omitted=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),HOST_A)
+ expect(JSON.parse(omitted.content[1].text).host).toBe('客厅 Mac')
+})
+it('keeps 本机 on the server when deviceHost is local even if a script machine is bound',async()=>{
+ agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
+ drive()
+ driveRemote(HOST_A,{...host,name:'客厅 Mac'})
+ const view=await service.call('owner',agent.id,'desktop_environment_view',{host:'本机'},new AbortController().signal,()=>{},'local',service.hostEpochs('owner',agent),'local')
+ expect(JSON.parse(view.content[1].text).host).toBe('测试 Mac')
+})
+it('deviceContextLine names the messaging host',()=>{
+ drive()
+ driveRemote(HOST_A,{...host,name:'客厅 Mac'})
+ expect(service.deviceContextLine('owner',HOST_A)).toContain('客厅 Mac')
+ expect(service.deviceContextLine('owner',HOST_A)).toContain('本机')
+ expect(service.onlineHostsLine('owner',HOST_A)).toContain('本机')
+})