Quellcode durchsuchen

完善机器人共享电脑、Grok 云端接管与定时任务侧栏

LSamien vor 1 Monat
Ursprung
Commit
0d02ca00d5
85 geänderte Dateien mit 1110 neuen und 280 gelöschten Zeilen
  1. 29 0
      docs/verification/2026-09-10-bot-panels/README.md
  2. BIN
      docs/verification/2026-09-10-bot-panels/desktop-computer.png
  3. BIN
      docs/verification/2026-09-10-bot-panels/desktop-inspector.png
  4. BIN
      docs/verification/2026-09-10-bot-panels/desktop-routine-logs.png
  5. BIN
      docs/verification/2026-09-10-bot-panels/desktop-routines.png
  6. BIN
      docs/verification/2026-09-10-bot-panels/phone-group-inspector.png
  7. 7 0
      playwright.bot-panels.config.ts
  8. 2 2
      src/client/api/profiles.ts
  9. 1 1
      src/client/components/app/AccountSecurityPanel.vue
  10. 2 2
      src/client/components/app/AgentIdentityDialog.vue
  11. 1 1
      src/client/components/app/AgentIdentityFixture.vue
  12. 1 1
      src/client/components/app/AgentIdentityPanel.vue
  13. 1 1
      src/client/components/app/DuplexVoicePanel.vue
  14. 10 9
      src/client/components/app/LocalVmSettingsPanel.vue
  15. 3 3
      src/client/components/app/ModelServicesPanel.vue
  16. 1 1
      src/client/components/app/RunnerSettingsPanel.vue
  17. 1 1
      src/client/components/app/ServerIdentityPanel.vue
  18. 5 5
      src/client/components/app/SettingsCenterDialog.vue
  19. 3 3
      src/client/components/app/SystemManagementPanel.vue
  20. 1 1
      src/client/components/app/SystemOverviewPanel.vue
  21. 10 10
      src/client/components/app/WorkspaceShell.vue
  22. 5 1
      src/client/components/common/AppIcon.vue
  23. 2 2
      src/client/components/composer/ComposerShell.vue
  24. 8 8
      src/client/components/groups/CreateGroupDialog.vue
  25. 11 11
      src/client/components/groups/GroupManager.vue
  26. 2 2
      src/client/components/groups/TopicTeamPickerDialog.vue
  27. 2 2
      src/client/components/kanban/KanbanTaskDrawer.vue
  28. 4 4
      src/client/components/kanban/presentation.ts
  29. 1 1
      src/client/components/messages/MessageTimeline.vue
  30. 7 6
      src/client/components/workspace/ComputerPanel.vue
  31. 1 1
      src/client/components/workspace/ConversationList.vue
  32. 56 22
      src/client/components/workspace/LocalVmChatPanel.vue
  33. 3 3
      src/client/components/workspace/LocalVmWorkspace.vue
  34. 3 3
      src/client/components/workspace/RemoteAgentPicker.vue
  35. 3 3
      src/client/components/workspace/TeamPresetPicker.vue
  36. 24 0
      src/client/components/workspace/WorkspaceBotPanel.vue
  37. 45 0
      src/client/components/workspace/WorkspaceInspectorPanel.vue
  38. 1 1
      src/client/components/workspace/WorkspaceNodesPanel.vue
  39. 65 0
      src/client/components/workspace/WorkspaceRoutinesPanel.vue
  40. 1 1
      src/client/components/workspace/WorkspaceView.vue
  41. 3 3
      src/client/components/workspace/viewModels.ts
  42. 2 2
      src/client/views/ArtifactsView.vue
  43. 22 21
      src/client/views/ConversationsView.vue
  44. 9 9
      src/client/views/GroupsView.vue
  45. 2 2
      src/client/views/KanbanView.vue
  46. 1 1
      src/runner/computers/localVm.ts
  47. 1 1
      src/runner/computers/pool.ts
  48. 4 4
      src/runner/worker/gateway.ts
  49. 2 2
      src/runner/worker/process.ts
  50. 13 0
      src/server/app.ts
  51. 2 2
      src/server/computerControls.ts
  52. 211 0
      src/server/grokCloud.ts
  53. 1 1
      src/server/localAuth.ts
  54. 4 3
      src/server/localVm.ts
  55. 1 1
      src/server/pairing.ts
  56. 1 1
      src/server/profileIdentities.ts
  57. 5 5
      src/server/pushEvents.ts
  58. 1 1
      src/server/realtimeApi.ts
  59. 1 1
      src/server/realtimeBroker.ts
  60. 1 1
      src/server/runnerHub.ts
  61. 3 3
      src/server/sessionWorkingDirectory.ts
  62. 12 10
      src/server/sharedComputers.ts
  63. 1 1
      src/server/taskCoordinator.ts
  64. 17 10
      src/server/workspaceGateway.ts
  65. 38 0
      src/server/workspaceInspector.ts
  66. 11 11
      src/server/workspaceRemoteAgents.ts
  67. 14 6
      src/server/workspaceRoutes.ts
  68. 86 0
      src/server/workspaceRoutines.ts
  69. 31 19
      src/server/workspaceRuntime.ts
  70. 1 1
      src/server/workspaceScheduler.ts
  71. 20 6
      src/server/workspaceStore.ts
  72. 12 12
      src/server/workspaceTeamTools.ts
  73. 2 1
      src/server/workspaceToolLease.ts
  74. 1 1
      src/shared/computerControl.ts
  75. 1 0
      src/shared/workspace.ts
  76. 17 0
      src/shared/workspacePanels.ts
  77. 1 1
      tests/client/agentManagementDialog.test.ts
  78. 5 5
      tests/client/groupHost.test.ts
  79. 1 1
      tests/client/kanbanView.test.ts
  80. 4 4
      tests/client/settingsCenterDialog.test.ts
  81. 6 6
      tests/client/workspaceShell.test.ts
  82. 70 0
      tests/e2e/bot-panels.spec.ts
  83. 7 7
      tests/e2e/workspace-chat.spec.ts
  84. 6 2
      tests/server/sharedComputers.test.ts
  85. 134 0
      tests/server/workspacePanels.test.ts

+ 29 - 0
docs/verification/2026-09-10-bot-panels/README.md

@@ -0,0 +1,29 @@
+# Bot 模式机器人侧栏与 Grok Bot 云端电脑
+
+## 完成内容
+
+- 共享本地虚拟机按同一账号、同一执行节点绑定多个机器人,不再按基础 Profile 分成多台。虚拟机运行数量上限单独管理;独立模式仍保留。共享电脑保留已确定的工作目录,机器人保留独立会话。
+- 机器人侧栏包含电脑、定时任务和 Inspector。电脑选择参照 OpenMausBot:自动、Grok Bot 云端、本地虚拟机、本机、仅浏览器、不使用电脑。当前执行节点没有本机控制与独立浏览器连接,因此对应两项不可选。
+- 自动选择保留可用本地虚拟机;新机器人可使用已就绪的本地执行节点,否则使用当前账号已连接的 Grok Bot 云端。查看侧栏只查询状态;显式打开云端电脑或实际工具执行才建立/唤醒连接。
+- Grok Bot 使用服务端保存的加密凭据,按账号复用同一台虚拟机。机器人通过当前基础 Hermes 的工具桥获得云端 shell、文件读写、截图与桌面输入工具;云端地址和认证令牌不下发到浏览器或手机。
+- 接管等待当前云端电脑操作完成,后续机器人电脑工具等待交还。两个页面不能同时取得控制;控制到期不自行恢复,重新接管并交还后才继续,交还说明进入后续工具结果。
+- 群聊侧栏只有 Inspector,没有电脑查看或控制入口。
+- 编辑机器人允许切换基础机器人,服务端校验来源授权、可用性和空闲状态。新执行使用新会话,近期聊天记录仍用于上下文。
+- 定时任务支持单次、间隔、每日、每周和明确时区。任务、下次执行时间与执行记录持久化,重启后补执行一次到期任务,不集中重放漏掉的所有周期;相同任务未结束时跳过重叠执行。结果进入机器人原聊天。
+- 定时任务排版对齐 OpenMausBot 的 RoutinesSection / RoutineList / RoutineLogs:顶部操作卡、独立任务卡、状态标记、下一次执行、最近结果、日志筛选与模态编辑器。仅编辑标题或提示词不会推迟原定执行时间。
+- Inspector 排版对齐 OpenMausBot 的 InspectorPanel:Events / Raw 切换、条目计数、刷新图标、等宽紧凑行、方向/事件标签、连续 delta 合并与按需展开原始记录。
+- Inspector 记录实际 Hermes 网关请求、响应、事件和错误,提供请求 ID、耗时与展开数据。每个聊天最多保留 2000 条,面板读取最新 400 条;凭据和附件数据隐藏,按账号/聊天/任务隔离,聊天删除时清理。
+- 用户界面的 Agent 文案改为机器人;协议字段、代码标识与原有文件路径保持兼容。
+
+## 验证
+
+- 使用独立测试目录验证 Grok Bot 0.47.0 连接:状态读取成功,真实 shell 返回 Linux x86_64、box、/workspace,退出码 0;读取到 1280×800 PNG 桌面截图。未向真实云端发送桌面输入或改动工作文件。
+- Web Playwright 使用一次性 Hermes fixture,覆盖编辑基础机器人、真实网关请求记录、定时任务创建/立即执行/完成、群聊移除电脑入口,以及 375px 深色/减少动态效果显示。这里验证应用链路,fixture 回复不作为真实模型推理证据。
+- 全量 Vitest:871 项通过、7 项按原配置跳过;生产构建通过。Web 完整侧栏流程 Playwright:1 项通过。iOS 本地签名模拟器:WorkspaceChatTests 33 项、界面测试 3 项通过。
+- `desktop-computer.png`、`desktop-inspector.png`、`desktop-routines.png`、`phone-group-inspector.png` 均为本次 fixture 的实际浏览器截图。
+
+## 启用
+
+管理员在机器人电脑侧栏的「Grok Bot 云端连接」中使用服务器上已有的 Grok Bot 登录,或提交有效登录凭据;客户端版本默认为 0.47.0。选择云端需要基础机器人所在 Hermes 已启用 `yaoyao-bot-bridge`,远程执行节点需支持同一工具桥协议。
+
+本次交付为 Web/桌面共用前端与服务端、iOS 源码和验证,不包含生产服务重启、桌面安装包发布或实体手机覆盖安装。

BIN
docs/verification/2026-09-10-bot-panels/desktop-computer.png


BIN
docs/verification/2026-09-10-bot-panels/desktop-inspector.png


BIN
docs/verification/2026-09-10-bot-panels/desktop-routine-logs.png


BIN
docs/verification/2026-09-10-bot-panels/desktop-routines.png


BIN
docs/verification/2026-09-10-bot-panels/phone-group-inspector.png


+ 7 - 0
playwright.bot-panels.config.ts

@@ -0,0 +1,7 @@
+import {defineConfig,devices} from '@playwright/test'
+export default defineConfig({
+ testDir:'./tests/e2e',testMatch:'bot-panels.spec.ts',workers:1,timeout:60000,reporter:'list',
+ use:{baseURL:'http://127.0.0.1:18832',viewport:{width:1440,height:900},trace:'retain-on-failure',screenshot:'only-on-failure'},
+ projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],
+ webServer:{command:'WORKSPACE_FIXTURE_PORT=18832 WORKSPACE_FIXTURE_UPSTREAM_PORT=19132 node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18832/healthz',reuseExistingServer:false,timeout:60000},
+})

+ 2 - 2
src/client/api/profiles.ts

@@ -46,7 +46,7 @@ function readProfileEntries(value: JsonValue): Record<string, JsonValue>[] {
 function validateIdentity(input: ProfileIdentityInput): ProfileIdentityInput {
   const title = input.title.trim().replace(/\s+/g, ' ')
   if (!title || title.length > MAX_AGENT_NAME_LENGTH || /[\u0000-\u001f\u007f]/.test(title)) {
-    throw new Error(`Agent 名称应为 1 至 ${MAX_AGENT_NAME_LENGTH} 个字符`)
+    throw new Error(`机器人名称应为 1 至 ${MAX_AGENT_NAME_LENGTH} 个字符`)
   }
   const avatarDataURL = input.avatarDataURL === undefined ? undefined : input.avatarDataURL?.trim() || null
   if (avatarDataURL && (avatarDataURL.length > MAX_AGENT_AVATAR_LENGTH || !avatarDataURLPattern.test(avatarDataURL))) {
@@ -79,7 +79,7 @@ export async function updateProfileIdentity(profile: Profile, input: ProfileIden
   try {
     const listed = await control.request('profiles.list', { include_sessions: false })
     const current = readProfileEntries(listed).find(item => String(item.name ?? '') === profile.name)
-    if (!current) throw new Error('该 Agent 已不存在,请刷新后重试')
+    if (!current) throw new Error('该机器人已不存在,请刷新后重试')
     const revisions = jsonObject(current.ui_meta_revisions)
     const revision = typeof revisions[YAOYAO_AGENT_IDENTITY_NAMESPACE] === 'number'
       ? revisions[YAOYAO_AGENT_IDENTITY_NAMESPACE] as number

+ 1 - 1
src/client/components/app/AccountSecurityPanel.vue

@@ -230,7 +230,7 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       <p v-else-if="notice" class="form-message form-message--success" role="status">{{ notice }}</p>
 
       <footer v-if="showActions" class="form-actions">
-        <span>保存后不会修改 Agent、Provider 或系统服务配置。</span>
+        <span>保存后不会修改机器人、Provider 或系统服务配置。</span>
         <button class="solid-button" type="submit" :disabled="!canSave">{{ busy ? '正在保存…' : '保存修改' }}</button>
       </footer>
     </form>

+ 2 - 2
src/client/components/app/AgentIdentityDialog.vue

@@ -21,9 +21,9 @@ watch(() => [props.open, props.profile] as const, () => {
   <Teleport to="body">
     <Transition name="identity-fade">
       <div v-if="open && profile" class="identity-layer" @mousedown.self="emit('close')">
-        <section class="identity-dialog" role="dialog" aria-modal="true" aria-label="Agent 管理">
+        <section class="identity-dialog" role="dialog" aria-modal="true" aria-label="机器人管理">
           <header><div><small>AGENT 管理</small><h2>{{ profile.agentName || profile.displayName || profile.name }}</h2></div><button class="icon-button" type="button" aria-label="关闭" :disabled="busy" @click="emit('close')"><AppIcon name="close" /></button></header>
-          <nav v-if="isAdmin" class="management-tabs" aria-label="Agent 管理分类"><button type="button" :class="{ active: activeTab === 'identity' }" @click="activeTab = 'identity'">身份</button><button type="button" :class="{ active: activeTab === 'models' }" @click="activeTab = 'models'">模型服务</button><button type="button" :class="{ active: activeTab === 'voice' }" @click="activeTab = 'voice'">双流语音</button></nav>
+          <nav v-if="isAdmin" class="management-tabs" aria-label="机器人管理分类"><button type="button" :class="{ active: activeTab === 'identity' }" @click="activeTab = 'identity'">身份</button><button type="button" :class="{ active: activeTab === 'models' }" @click="activeTab = 'models'">模型服务</button><button type="button" :class="{ active: activeTab === 'voice' }" @click="activeTab = 'voice'">双流语音</button></nav>
           <AgentIdentityPanel v-if="activeTab === 'identity'" :profile="profile" :busy="busy" :error="error" @save="emit('save', $event)">
             <template #actions>
               <button class="quiet-button" type="button" :disabled="busy" @click="emit('close')">取消</button>

+ 1 - 1
src/client/components/app/AgentIdentityFixture.vue

@@ -23,7 +23,7 @@ const teamMembers = shapes.map(item => ({ name: item.name, avatar: item.avatar }
   <main class="identity-fixture">
     <header>
       <span>跨端角色系统</span>
-      <h1>自定义 Agent 角色</h1>
+      <h1>自定义机器人角色</h1>
       <p>采用 OpenMausBot 的纯色轮廓、五官与状态动画。</p>
     </header>
     <section class="shape-row" aria-label="三种头像形状">

+ 1 - 1
src/client/components/app/AgentIdentityPanel.vue

@@ -212,7 +212,7 @@ function resetAvatar() {
 
 function submit() {
   const normalized = title.value.trim().replace(/\s+/g, ' ')
-  if (!normalized) { localError.value = '请输入 Agent 名称'; return }
+  if (!normalized) { localError.value = '请输入机器人名称'; return }
   emit('save', {
     title: normalized,
     avatarMode: avatarMode.value,

+ 1 - 1
src/client/components/app/DuplexVoicePanel.vue

@@ -94,7 +94,7 @@ onMounted(() => { void load() })
 
 <template>
   <section class="voice-panel" aria-label="双流语音">
-    <div class="voice-heading"><div><h3>yaoyao 双流语音</h3><p>这是整个 yaoyao 安装共享的全局设置,不随当前 Agent 切换。</p></div><span>全局</span></div>
+    <div class="voice-heading"><div><h3>yaoyao 双流语音</h3><p>这是整个 yaoyao 安装共享的全局设置,不随当前机器人切换。</p></div><span>全局</span></div>
     <p v-if="error" class="voice-error" role="alert">{{ error }}</p><p v-else-if="notice" class="voice-notice" role="status">{{ notice }}</p>
     <p v-if="loading" class="voice-empty">正在读取语音设置…</p>
     <form v-else :aria-busy="busy" @submit.prevent="save">

+ 10 - 9
src/client/components/app/LocalVmSettingsPanel.vue

@@ -25,7 +25,7 @@ async function refresh(){
 async function action(work:()=>Promise<unknown>){acting.value=true;error.value='';try{await work()}catch(e){error.value=e instanceof Error?e.message:'操作未完成'}finally{acting.value=false;await refresh()}}
 function prepare(){void action(()=>apiRequest('/api/app/admin/local-vm/prepare',{method:'POST',body:{requestId:createUuid()}}))}
 function policy(mode:LocalVmMode,maxInstances=state.value?.maxInstances??2){
- if(mode==='shared'&&state.value?.mode!=='shared'&&!confirm('同一账号、同一基础 Agent 的机器人将共用桌面、工作文件和浏览器登录。确认使用共享虚拟机?'))return
+ if(mode==='shared'&&state.value?.mode!=='shared'&&!confirm('当前账号的多个机器人将共用桌面、工作文件和浏览器登录。确认使用共享虚拟机?'))return
  void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
 }
 function manage(agent:WorkspaceAgent,operation:LocalVmAction){
@@ -37,30 +37,31 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
 </script>
 <template>
  <section class="local-vm-settings" aria-label="本地虚拟机设置">
-  <article><h3>{{state?.fixedCapacity?'Compose 共享桌面':'本地虚拟机'}}</h3><p>{{state?.fixedCapacity?'桌面容器随 Web 的 Compose 文件部署,工作文件与浏览器资料保存在各桌面的数据卷中。':'在 Hermes 所在电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。'}}</p>
+  <article><h3>{{state?.fixedCapacity?'Compose 共享桌面':'本地虚拟机'}}</h3><p>{{state?.fixedCapacity?'桌面容器随 Web 的 Compose 文件部署,工作文件与浏览器资料保存在各桌面的数据卷中。':'在 Hermes 所在电脑的隔离 Linux 桌面中运行机器人,工作文件与浏览器资料持续保存。'}}</p>
    <div class="row"><span class="status"><AppIcon :name="state?.image?'check':'monitor'"/>{{loading&&!state?'正在检查…':state?.image?'虚拟机环境已就绪':'尚未就绪'}}</span><button :disabled="loading||acting" @click="error='';refresh()"><AppIcon name="refresh"/>重新检查</button></div>
    <p v-if="state?.problem">{{state.problem}}</p><p v-if="error" role="alert">{{error}}</p>
    <p v-if="state?.job" role="status">{{state.job.message}}</p>
   </article>
   <article v-if="state?.executionHost==='runner'"><h3>Hermes 执行连接</h3><p>{{state.fixedCapacity?'执行连接负责 Hermes 模型与工具调用,桌面容器完全由这套 Compose 管理。':'虚拟桌面由运行 Hermes 的执行节点提供,Docker 版 Web 通过这条连接使用桌面。'}}{{state.runnerName?`当前节点:${state.runnerName}。`:''}}</p><button :aria-expanded="runnerSettingsOpen" @click="runnerSettingsOpen=!runnerSettingsOpen">{{runnerSettingsOpen?'收起执行节点设置':'打开执行节点设置'}}</button><RunnerSettingsPanel v-if="runnerSettingsOpen" computer-setup /></article>
-  <article v-if="state?.fixedCapacity"><h3>Compose 共享桌面</h3><p>已配置 {{state.maxInstances}} 台桌面,数量由 Compose 固定。请在 Agent 聊天的电脑面板选择已有桌面。</p><div v-for="desktop in state.desktops" :key="desktop.id" class="instance"><strong>{{desktop.name}}</strong><span>{{desktop.ready?'已就绪':desktop.online?'正在启动':'未连接'}}</span></div></article>
-  <article v-else-if="state"><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
-   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
-   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
+  <article v-if="state?.fixedCapacity"><h3>Compose 共享桌面</h3><p>已配置 {{state.maxInstances}} 台桌面,数量由 Compose 固定。请在机器人聊天的电脑面板选择已有桌面。</p><div v-for="desktop in state.desktops" :key="desktop.id" class="instance"><strong>{{desktop.name}}</strong><span>{{desktop.ready?'已就绪':desktop.online?'正在启动':'未连接'}}</span></div></article>
+  <article v-else-if="state"><h3>隔离方式</h3><p>按机器人创建独立桌面,或让可信的机器人共用一个桌面。</p>
+   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('per-bot')">每个机器人独立</button></div>
+   <p class="sharing-explanation">共享指多个机器人操作同一台虚拟机,共用桌面和工作文件;与可同时运行的虚拟机数量无关。</p>
+   <label class="row">虚拟机运行数量上限<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
   </article>
   <article v-if="state&&!state.fixedCapacity&&!state.setupRequired"><h3>准备运行环境</h3><p v-if="state?.executionHost==='runner'">以下检查和镜像准备均在执行节点所在电脑完成。</p><ol>
    <li><span class="step">{{state?.runtime?'✓':'1'}}</span><div><strong>安装 Docker 或 Podman</strong><p>{{state?.runtime?`已检测到 ${state.runtime}`:'请先安装 Docker Desktop 或 Podman。'}}</p></div></li>
    <li><span class="step">{{state?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>
    <li><span class="step">{{state?.image?'✓':'3'}}</span><div><strong>准备本地虚拟机</strong><p>下载并检查托管桌面环境。后续创建虚拟机时会自动使用。</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state?.image?'重新检查虚拟机镜像':'准备本地虚拟机'}}</button></div></li>
-   <li><span class="step">4</span><div><strong>从 Agent 聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该 Agent 的虚拟机。</p></div></li>
+   <li><span class="step">4</span><div><strong>从机器人聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该机器人的虚拟机。</p></div></li>
   </ol></article>
-  <article v-if="state&&!state.fixedCapacity"><h3>{{state?.mode==='shared'?'共享虚拟机':'Agent 虚拟机'}}</h3><p v-if="!instances.length">还没有 Agent 使用本地虚拟机。</p>
+  <article v-if="state&&!state.fixedCapacity"><h3>{{state?.mode==='shared'?'共享虚拟机':'机器人虚拟机'}}</h3><p v-if="!instances.length">还没有机器人使用本地虚拟机。</p>
    <div v-for="row in instances" :key="row.agent.id" class="instance"><div><strong>{{row.agent.computerEnvironmentName??row.agent.name}}</strong><p>{{row.vm.inUse?'正在使用':({running:'运行中',stopped:'已停止',missing:'尚未创建'})[row.vm.container]}}</p></div><div class="row">
     <button v-if="row.vm.container==='running'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'stop')">停止</button>
     <button v-else :disabled="acting||!state?.image" @click="manage(row.agent,row.vm.container==='missing'?'create':'start')">{{row.vm.container==='missing'?'创建':'启动'}}</button>
     <button v-if="row.vm.container!=='missing'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'remove')">移除实例</button>
    </div></div>
-   <div v-for="item in state?.instances?.filter(i=>i.orphaned)" :key="item.id" class="instance"><div><strong>{{item.name}}</strong><p>暂无 Agent 使用 · {{item.status==='free'?'已停止':'运行中或待核对'}}</p></div><div class="row"><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/stop`,{method:'POST',body:{requestId:createUuid()}}))">停止</button><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/remove`,{method:'POST',body:{requestId:createUuid()}}))">移除实例</button></div></div>
+   <div v-for="item in state?.instances?.filter(i=>i.orphaned)" :key="item.id" class="instance"><div><strong>{{item.name}}</strong><p>暂无机器人使用 · {{item.status==='free'?'已停止':'运行中或待核对'}}</p></div><div class="row"><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/stop`,{method:'POST',body:{requestId:createUuid()}}))">停止</button><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/remove`,{method:'POST',body:{requestId:createUuid()}}))">移除实例</button></div></div>
   </article>
  </section>
 </template>

+ 3 - 3
src/client/components/app/ModelServicesPanel.vue

@@ -266,7 +266,7 @@ async function activate(service: CustomModelService) {
     await activateModelService(props.profile, service.id)
     await load(true)
     notifyModelCatalogChanged(props.profile)
-    notice.value = `“${service.name}”已设为当前 Agent 的默认模型服务`
+    notice.value = `“${service.name}”已设为当前机器人的默认模型服务`
   } catch (cause) {
     error.value = cause instanceof Error ? cause.message : '设为默认失败'
   } finally {
@@ -299,7 +299,7 @@ onMounted(() => { void load() })
 
 <template>
   <section class="management-panel" aria-label="模型服务">
-    <div class="panel-context"><strong>9119 Provider 与模型</strong><span>全部来自当前 Agent:<b>{{ profile }}</b></span></div>
+    <div class="panel-context"><strong>9119 Provider 与模型</strong><span>全部来自当前机器人:<b>{{ profile }}</b></span></div>
     <form class="default-model-card" @submit.prevent="saveDefaultModel">
       <label><strong>默认全局模型</strong><small>仅用于之后新建的会话,已有会话保持原模型。</small></label>
       <select v-model="defaultModelKey" aria-label="默认全局模型" :disabled="busy || loading">
@@ -331,7 +331,7 @@ onMounted(() => { void load() })
       <p v-else class="editor-hint">该 Provider 的密钥不由 9119 环境变量管理,因此这里只编辑 URL 和模型。</p>
       <button v-if="draft.canEditApiKey && draft.hasApiKey && !draft.clearApiKey" class="text-button danger" type="button" :disabled="busy" @click="draft.apiKey = ''; draft.apiKeyTouched = false; draft.clearApiKey = true">清除已保存密钥</button><button v-else-if="draft.canEditApiKey && draft.clearApiKey" class="text-button" type="button" :disabled="busy" @click="draft.clearApiKey = false">取消清除密钥</button>
       <label>模型列表(每行一个)<textarea v-model="draft.modelsText" rows="5" :disabled="busy" placeholder="连接测试后自动填充,也可手动输入" /></label>
-      <label class="check-row"><input v-model="draft.discoverModels" type="checkbox" :disabled="busy" />允许 9119 自动发现模型</label><label v-if="draft.source === 'managed'" class="check-row"><input v-model="draft.makeDefault" type="checkbox" :disabled="busy" />保存后设为当前 Agent 默认服务</label>
+      <label class="check-row"><input v-model="draft.discoverModels" type="checkbox" :disabled="busy" />允许 9119 自动发现模型</label><label v-if="draft.source === 'managed'" class="check-row"><input v-model="draft.makeDefault" type="checkbox" :disabled="busy" />保存后设为当前机器人默认服务</label>
       <small class="editor-hint">{{ draft.source === 'legacy' ? '保存后模型列表按当前内容更新;当前默认 Provider 会同步新的 URL 与默认模型。' : '9119 会保留服务中已有的模型条目;删除整项服务会一并移除它们。' }}</small>
       <footer><button class="quiet-button" type="button" :disabled="busy" @click="probe">测试连接</button><button class="primary-button" type="submit" :disabled="busy">{{ busy ? '处理中…' : '保存模型服务' }}</button></footer>
     </form>

+ 1 - 1
src/client/components/app/RunnerSettingsPanel.vue

@@ -86,7 +86,7 @@ onMounted(()=>{void action(refresh)})
         <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" placeholder="留空使用默认安装"></label>
         <small>留空时在执行电脑上查找 ~/.hermes/hermes-agent/venv/bin/python。自定义安装请填写绝对路径;镜像可在节点连接后于本页准备。</small>
-        <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从 Agent 聊天的电脑面板启用。</small>
+        <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从机器人聊天的电脑面板启用。</small>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>

+ 1 - 1
src/client/components/app/ServerIdentityPanel.vue

@@ -34,7 +34,7 @@ void auth.refreshServerIdentity()
     <h3>服务器名称</h3>
     <form v-if="editable" @submit.prevent="save">
       <label>统一显示名称<input v-model="name" name="server-display-name" maxlength="100" placeholder="例如:家里的 Mac" :disabled="busy" /></label>
-      <p>该服务器上的 Agent 共用此名称,Web、iOS 和安卓同步。留空恢复服务器主机名。</p>
+      <p>该服务器上的机器人共用此名称,Web、iOS 和安卓同步。留空恢复服务器主机名。</p>
       <p v-if="error" role="alert">{{ error }}</p><p v-if="notice" role="status">{{ notice }}</p>
       <button type="submit" :disabled="busy || !dirty">{{ busy ? '保存中…' : '保存服务器名称' }}</button>
       <button v-if="error" type="button" :disabled="busy" @click="reload">重新载入</button>

+ 5 - 5
src/client/components/app/SettingsCenterDialog.vue

@@ -148,7 +148,7 @@ const activeScope = computed(() => {
 })
 
 function profileTitle(profile?: Profile): string {
-  return profile?.agentName || profile?.displayName || profile?.name || '未选择 Agent'
+  return profile?.agentName || profile?.displayName || profile?.name || '未选择机器人'
 }
 
 function setDirty(page: SettingsPage, dirty: boolean) {
@@ -283,10 +283,10 @@ function requestModeSwitch() {
                 <div v-if="showAgentSelector" class="settings-agent-selector">
                   <button ref="settingsAgentTrigger" type="button" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleAgentMenu">
                     <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="34" />
-                    <span><strong>{{ profileTitle(activeProfile) }}</strong><small>{{ activeProfile?.name || '未选择 Agent' }}</small></span>
+                    <span><strong>{{ profileTitle(activeProfile) }}</strong><small>{{ activeProfile?.name || '未选择机器人' }}</small></span>
                     <AppIcon name="chevron-down" :size="14" />
                   </button>
-                  <div v-if="profileMenuOpen" class="settings-agent-menu" role="listbox" aria-label="切换正在设置的 Agent" @keydown="handleAgentMenuKeydown">
+                  <div v-if="profileMenuOpen" class="settings-agent-menu" role="listbox" aria-label="切换正在设置的机器人" @keydown="handleAgentMenuKeydown">
                     <button v-for="profile in profiles" :key="profile.name" type="button" role="option" :aria-selected="profile.name === activeProfile?.name" @click="selectProfile(profile.name)">
                       <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
                       <span><strong>{{ profileTitle(profile) }}</strong><small>{{ profile.name }}</small></span>
@@ -302,7 +302,7 @@ function requestModeSwitch() {
                 <nav>
                   <section v-if="isAdmin"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
                   <section>
-                    <h3>当前 Agent</h3>
+                    <h3>当前机器人</h3>
                     <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
                   </section>
                   <section>
@@ -336,7 +336,7 @@ function requestModeSwitch() {
                   @dirty-change="setDirty('agent-identity', $event)"
                   @save="emit('save-identity', $event)"
                 />
-                <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择 Agent。</p>
+                <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择机器人。</p>
                 <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
                 <AccountSecurityPanel
                   v-else-if="activePage === 'account-security'"

+ 3 - 3
src/client/components/app/SystemManagementPanel.vue

@@ -414,8 +414,8 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
           <small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small>
         </span>
         <template v-if="user.role !== 'admin'">
-          <label class="user-assignment">基础 Agent
-            <select v-model="userAssignments[user.id]" multiple :disabled="busy" :aria-label="`分配给 ${user.username} 的基础 Agent`">
+          <label class="user-assignment">基础机器人
+            <select v-model="userAssignments[user.id]" multiple :disabled="busy" :aria-label="`分配给 ${user.username} 的基础机器人`">
               <option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option>
             </select>
           </label>
@@ -428,7 +428,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       <form class="user-create-form" aria-label="创建用户" @submit.prevent="add">
         <label><span>新用户名</span><input v-model="username" name="managed-username" autocomplete="off" :disabled="busy" /></label>
         <label><span>临时密码</span><input v-model="password" name="managed-temporary-password" type="password" autocomplete="new-password" :disabled="busy" /><small>至少 8 位</small></label>
-        <label class="create-assignment"><span>分配基础 Agent</span><select v-model="assignedProfiles" multiple :disabled="busy" aria-label="新子账号的基础 Agent"><option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option></select><small>子账号仅可使用 Bot 模式,基于分配的 Agent 创建自己的 Agent;未分配时不可创建。保存权限后需重新登录。</small></label>
+        <label class="create-assignment"><span>分配基础机器人</span><select v-model="assignedProfiles" multiple :disabled="busy" aria-label="新子账号的基础机器人"><option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.agentName || profile.displayName || profile.name }}</option></select><small>子账号仅可使用 Bot 模式,基于分配的机器人创建自己的机器人;未分配时不可创建。保存权限后需重新登录。</small></label>
         <button class="solid-button" :disabled="busy || !username.trim() || password.length < 8">创建用户</button>
       </form>
     </div>

+ 1 - 1
src/client/components/app/SystemOverviewPanel.vue

@@ -71,7 +71,7 @@ watch(() => props.active, active => { if (active) void refresh() }, { immediate:
 <template>
   <section class="system-overview-panel" aria-label="系统概览">
     <div class="overview-toolbar">
-      <p>影响整个夭夭安装及所有 Agent,仅管理员可见。</p>
+      <p>影响整个夭夭安装及所有机器人,仅管理员可见。</p>
       <button type="button" :disabled="checking" @click="refresh"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查系统状态' }}</button>
     </div>
     <p v-if="error" class="overview-warning" role="status">{{ error }}</p>

+ 10 - 10
src/client/components/app/WorkspaceShell.vue

@@ -119,7 +119,7 @@ const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
 function profileTitle(profile?: Profile): string {
-  return profile?.agentName || profile?.displayName || profile?.name || '未选择 Agent'
+  return profile?.agentName || profile?.displayName || profile?.name || '未选择机器人'
 }
 
 const navItems: NavItem[] = [
@@ -469,12 +469,12 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 
       <div class="sidebar-footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换 Agent:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
+          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
             <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
-              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent') }}</span>
+              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择机器人') }}</span>
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
@@ -482,8 +482,8 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
-            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换 Agent" @keydown="handleProfileMenuKeydown">
-              <strong class="profile-menu__heading">切换 Agent</strong>
+            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换机器人" @keydown="handleProfileMenuKeydown">
+              <strong class="profile-menu__heading">切换机器人</strong>
               <button
                 v-for="profile in profiles"
                 :key="profile.name"
@@ -576,12 +576,12 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 
       <div class="sidebar-footer mobile-drawer__footer">
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换 Agent:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
+          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
             <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
               <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
-              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择 Agent') }}</span>
+              <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择机器人') }}</span>
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
@@ -589,8 +589,8 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
-            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换 Agent" @keydown="handleProfileMenuKeydown">
-              <strong class="profile-menu__heading">切换 Agent</strong>
+            <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换机器人" @keydown="handleProfileMenuKeydown">
+              <strong class="profile-menu__heading">切换机器人</strong>
               <button
                 v-for="profile in profiles"
                 :key="profile.name"
@@ -660,7 +660,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
         <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
-          <button v-if="isAdmin" type="button" role="menuitem" @click="chooseCreate('remote-agent')"><AppIcon name="users" :size="17" />添加远程 Agent</button>
+          <button v-if="isAdmin" type="button" role="menuitem" @click="chooseCreate('remote-agent')"><AppIcon name="users" :size="17" />添加远程机器人</button>
           <button type="button" role="menuitem" @click="chooseCreate('group')"><AppIcon name="groups" :size="17" />新建群聊</button>
         </div>
       </div>

+ 5 - 1
src/client/components/common/AppIcon.vue

@@ -49,6 +49,8 @@ type IconName =
   | 'globe'
   | 'bell'
   | 'monitor'
+  | 'calendar'
+  | 'bug'
 
 withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
 </script>
@@ -66,7 +68,9 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     stroke-linejoin="round"
     aria-hidden="true"
   >
-    <template v-if="name === 'monitor'"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></template>
+    <template v-if="name === 'calendar'"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 11h18M8 15h2M14 15h2M8 18h2"/></template>
+    <template v-else-if="name === 'bug'"><path d="M8 9h8v7a4 4 0 0 1-8 0V9ZM9 9V6a3 3 0 0 1 6 0v3M12 10v10M4 10h4M16 10h4M3 15h5M16 15h5M5 21l3-3M16 18l3 3M5 4l4 3M15 7l4-3"/></template>
+    <template v-else-if="name === 'monitor'"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></template>
     <template v-else-if="name === 'chat'">
       <path d="M8 18h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-5l-6 4z" />
       <path d="M8 9h8M8 13h5" />

+ 2 - 2
src/client/components/composer/ComposerShell.vue

@@ -417,7 +417,7 @@ defineExpose({
 
     <div v-if="mode === 'group'" class="composer-activity-slot">
       <Transition name="composer-activity">
-        <div v-if="activityText" class="composer-activity" role="status" aria-label="Agent 输入状态" aria-live="polite" aria-atomic="true">
+        <div v-if="activityText" class="composer-activity" role="status" aria-label="机器人输入状态" aria-live="polite" aria-atomic="true">
           <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
           <strong>{{ activityText }}</strong>
         </div>
@@ -533,7 +533,7 @@ defineExpose({
 
       <Transition name="composer-menu">
         <div v-if="menuKind && menuOptions.length" class="composer-menu" role="listbox">
-          <div class="composer-menu__label">{{ menuKind === 'mention' ? '提及 Agent' : '命令' }}</div>
+          <div class="composer-menu__label">{{ menuKind === 'mention' ? '提及机器人' : '命令' }}</div>
           <button
             v-for="(option, index) in menuOptions"
             :key="option.id"

+ 8 - 8
src/client/components/groups/CreateGroupDialog.vue

@@ -152,12 +152,12 @@ function create() {
         <section class="create-dialog" role="dialog" aria-modal="true" aria-labelledby="create-group-title">
           <header><div><small>9119 团队</small><h2 id="create-group-title">新建团队</h2></div><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
           <section class="preset-picker" aria-labelledby="team-preset-label">
-            <div class="preset-picker__heading"><span id="team-preset-label">团队预设</span><small>选择后自动分配现有 Agent</small></div>
+            <div class="preset-picker__heading"><span id="team-preset-label">团队预设</span><small>选择后自动分配现有机器人</small></div>
             <div class="preset-grid">
               <button type="button" :class="{ selected: selectedPresetId === 'custom' }" @click="choosePreset()">
-                <strong>自定义团队</strong><small>手动选择 Agent 和协作配置</small><em>自由配置</em>
+                <strong>自定义团队</strong><small>手动选择机器人和协作配置</small><em>自由配置</em>
               </button>
-              <button v-for="preset in TEAM_PRESETS" :key="preset.id" type="button" :class="{ selected: selectedPresetId === preset.id }" :disabled="presetShortage(preset) > 0" :aria-label="`${preset.name},${preset.roles.length} 人${presetShortage(preset) ? `,还缺 ${presetShortage(preset)} 个 Agent` : ''}`" @click="choosePreset(preset)">
+              <button v-for="preset in TEAM_PRESETS" :key="preset.id" type="button" :class="{ selected: selectedPresetId === preset.id }" :disabled="presetShortage(preset) > 0" :aria-label="`${preset.name},${preset.roles.length} 人${presetShortage(preset) ? `,还缺 ${presetShortage(preset)} 个机器人` : ''}`" @click="choosePreset(preset)">
                 <strong>{{ preset.name }}</strong><small>{{ preset.summary }}</small><em>{{ presetShortage(preset) ? `还缺 ${presetShortage(preset)} 人` : `${preset.roles.length} 人` }}</em>
               </button>
             </div>
@@ -169,22 +169,22 @@ function create() {
             <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
           </section>
           <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
-          <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
+          <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有机器人查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
           <section v-if="selectedPreset" class="role-mapping" aria-labelledby="role-mapping-label">
-            <div class="role-mapping__heading"><span id="role-mapping-label">角色分配</span><small>{{ selectedPreset.roles.length }} 个角色已对应当前 Agent</small></div>
+            <div class="role-mapping__heading"><span id="role-mapping-label">角色分配</span><small>{{ selectedPreset.roles.length }} 个角色已对应当前机器人</small></div>
             <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
               <span><strong>{{ role.name }}<em v-if="hostEnabled && role.host">管理员</em></strong><small>{{ role.description }}</small></span>
-              <select :value="selected[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignRole(index, ($event.target as HTMLSelectElement).value)">
+              <select :value="selected[index]" :aria-label="`${role.name}对应的机器人`" @change="assignRole(index, ($event.target as HTMLSelectElement).value)">
                 <option v-for="profile in profiles" :key="profile.id" :value="profile.id">{{ profile.displayName }} · {{ profile.nodeLabel }}</option>
               </select>
             </label>
           </section>
           <div v-else class="agent-picker">
-            <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
+            <div class="agent-picker__heading"><span>选择机器人</span><small>{{ selected.length }}/8</small></div>
             <button v-for="profile in profiles" :key="profile.id" type="button" :class="{ selected: selected.includes(profile.id) }" :disabled="!selected.includes(profile.id) && selected.length >= 8" @click="toggle(profile)">
               <AgentAvatar :name="profile.displayName" :avatar="profile.avatar || ''" :size="28" /><strong>{{ profile.displayName }}<small>{{ profile.profile }} · {{ profile.nodeLabel }}</small></strong><AppIcon v-if="selected.includes(profile.id)" name="check" :size="15" />
             </button>
-            <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
+            <p v-if="!profiles.length">当前没有可用机器人,请先在 Hermes 配置 Profile。</p>
           </div>
           <label v-if="hostEnabled && !selectedPreset" class="host-picker">
             <span>管理员<small>用户没有明确 @ 时,管理员始终负责回应。</small></span>

+ 11 - 11
src/client/components/groups/GroupManager.vue

@@ -270,11 +270,11 @@ function statusLabel(status: GroupAgent['status']): string {
         <input ref="avatarInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
       </div>
       <p v-if="avatarError" class="avatar-error" role="alert">{{ avatarError }}</p>
-      <label v-if="roomInstructionsEnabled"><span>说明<small>所有 Agent 都会在回复前查阅,可填写协作规则和形式准则。</small></span><textarea v-model="form.instructions" maxlength="4000" rows="5" aria-label="团队说明" placeholder="例如:先核对事实;结论使用中文;发布前等待确认。" @change="saveRoom" /></label>
+      <label v-if="roomInstructionsEnabled"><span>说明<small>所有机器人都会在回复前查阅,可填写协作规则和形式准则。</small></span><textarea v-model="form.instructions" maxlength="4000" rows="5" aria-label="团队说明" placeholder="例如:先核对事实;结论使用中文;发布前等待确认。" @change="saveRoom" /></label>
       <label class="rounds"><span>最多回复轮数<small>-1 表示无限</small></span><input v-model.number="form.replyRounds" type="number" min="-1" max="100" aria-label="最多回复轮数" @change="saveRoom" /></label>
       <label v-if="hostFlowEnabled" class="flow-mode">
         <span>协作模式<small>管理员可按依赖逐步调度,也可一次 @ 多人并列执行。</small></span>
-        <select v-model="form.orchestrationMode" aria-label="协作模式" :disabled="busy || hasActiveAgents" :title="hasActiveAgents ? '请等待当前回复完成或先中断 Agent' : ''" @change="saveRoom">
+        <select v-model="form.orchestrationMode" aria-label="协作模式" :disabled="busy || hasActiveAgents" :title="hasActiveAgents ? '请等待当前回复完成或先中断机器人' : ''" @change="saveRoom">
           <option value="free">自由讨论</option>
           <option value="host">管理员协调</option>
         </select>
@@ -301,8 +301,8 @@ function statusLabel(status: GroupAgent['status']): string {
       </div>
 
       <div v-if="agents.length < 8 && availableProfiles?.length" class="add-agent">
-        <select aria-label="选择要添加的 Agent" @change="($event.target as HTMLSelectElement).value && emit('addAgent', ($event.target as HTMLSelectElement).value); ($event.target as HTMLSelectElement).value = ''">
-          <option value="">添加 Agent…</option>
+        <select aria-label="选择要添加的机器人" @change="($event.target as HTMLSelectElement).value && emit('addAgent', ($event.target as HTMLSelectElement).value); ($event.target as HTMLSelectElement).value = ''">
+          <option value="">添加机器人…</option>
           <option v-for="profile in availableProfiles" :key="profile.id" :value="profile.id">{{ profile.displayName }} · {{ profile.nodeLabel }}</option>
         </select>
       </div>
@@ -316,18 +316,18 @@ function statusLabel(status: GroupAgent['status']): string {
 
   <Teleport to="body">
     <div v-if="selectedAgent && agentDrafts[selectedAgent.id]" class="agent-settings-backdrop" @click.self="closeAgentSettings">
-      <section class="agent-settings-dialog" role="dialog" aria-modal="true" :aria-label="`${selectedAgent.displayName} Agent 设置`" @keydown.esc="closeAgentSettings">
+      <section class="agent-settings-dialog" role="dialog" aria-modal="true" :aria-label="`${selectedAgent.displayName}机器人设置`" @keydown.esc="closeAgentSettings">
         <header>
           <span>
-            <small>Agent 设置</small>
+            <small>机器人设置</small>
             <strong class="settings-agent-name">{{ selectedAgent.displayName }}<em v-if="hostEnabled && selectedAgent.isHost" class="host-badge">管理员</em></strong>
           </span>
-          <button class="agent-settings-close" type="button" aria-label="关闭 Agent 设置" title="关闭 Agent 设置" @click="closeAgentSettings"><AppIcon name="close" :size="17" /></button>
+          <button class="agent-settings-close" type="button" aria-label="关闭机器人设置" title="关闭机器人设置" @click="closeAgentSettings"><AppIcon name="close" :size="17" /></button>
         </header>
         <fieldset class="agent-editor">
-          <legend class="sr-only">{{ selectedAgent.displayName }} Agent 设置</legend>
+          <legend class="sr-only">{{ selectedAgent.displayName }} 机器人设置</legend>
           <div class="editor-grid">
-            <label v-if="isRemoteAgent(selectedAgent)"><span>群内名称<small>仅影响此群,会同步到其他设备;不修改远端 Agent 名称。</small></span><input v-model="agentDrafts[selectedAgent.id].displayName" maxlength="100" aria-label="群内名称" @input="markDirty(selectedAgent.id)" /></label>
+            <label v-if="isRemoteAgent(selectedAgent)"><span>群内名称<small>仅影响此群,会同步到其他设备;不修改远端机器人名称。</small></span><input v-model="agentDrafts[selectedAgent.id].displayName" maxlength="100" aria-label="群内名称" @input="markDirty(selectedAgent.id)" /></label>
             <label><span>职责说明</span><textarea v-model="agentDrafts[selectedAgent.id].description" rows="3" maxlength="500" aria-label="职责说明" @input="markDirty(selectedAgent.id)" /></label>
             <label>
               <span>模型</span>
@@ -358,11 +358,11 @@ function statusLabel(status: GroupAgent['status']): string {
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
             <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
           </div>
-          <p v-if="hostEnabled" class="host-explanation">{{ form.orchestrationMode === 'host' ? '管理员协调下此开关暂不触发自动并发;切回自由讨论后继续按原设置生效。' : (selectedAgent.isHost ? '管理员始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由管理员兜底。') }}</p>
+          <p v-if="hostEnabled" class="host-explanation">{{ form.orchestrationMode === 'host' ? '管理员协调下此开关暂不触发自动并发;切回自由讨论后继续按原设置生效。' : (selectedAgent.isHost ? '管理员始终处理用户无 @ 消息;此开关仅决定是否自动参与机器人发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由管理员兜底。') }}</p>
           <p v-if="agentUpdateError?.[selectedAgent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[selectedAgent.id] }}</p>
           <div class="editor-actions">
             <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="resetAgent(selectedAgent)">取消更改</button>
-            <button class="save-agent" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="saveAgent(selectedAgent)">保存 Agent 设置</button>
+            <button class="save-agent" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="saveAgent(selectedAgent)">保存机器人设置</button>
           </div>
         </fieldset>
       </section>

+ 2 - 2
src/client/components/groups/TopicTeamPickerDialog.vue

@@ -40,7 +40,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
             <article v-for="room in rooms" :key="room.id" :class="{ current: room.id === currentRoomId, expanded: expandedRoomId === room.id }">
               <button class="topic-team-picker__choose" type="button" @click="emit('select', room.id)">
                 <TeamAvatar :name="room.name" :avatar="room.avatar || ''" :members="room.avatarMembers?.map(member => ({ name: member.displayName })) || []" :fallback-key="room.id" :size="34" />
-                <span class="topic-team-picker__copy"><strong>{{ room.name }}</strong><small>{{ room.agentCount }} 个 Agent</small></span>
+                <span class="topic-team-picker__copy"><strong>{{ room.name }}</strong><small>{{ room.agentCount }} 个机器人</small></span>
                 <em v-if="room.id === currentRoomId">当前</em>
                 <AppIcon class="topic-team-picker__next" name="chevron-left" :size="15" />
               </button>
@@ -50,7 +50,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
               <section v-if="expandedRoomId === room.id" class="topic-team-picker__details" :aria-label="`${room.name}团队详情`">
                 <p><strong>团队说明</strong><span>{{ room.instructions || '未填写团队说明' }}</span></p>
                 <dl>
-                  <div><dt>成员</dt><dd>{{ room.avatarMembers?.map(member => member.displayName).join('、') || `${room.agentCount} 个 Agent` }}</dd></div>
+                  <div><dt>成员</dt><dd>{{ room.avatarMembers?.map(member => member.displayName).join('、') || `${room.agentCount} 个机器人` }}</dd></div>
                   <div><dt>协作方式</dt><dd>{{ room.orchestrationMode === 'host' ? '管理员协调' : '自由讨论' }}</dd></div>
                   <div><dt>最多回复</dt><dd>{{ room.maxReplyRounds }} 轮</dd></div>
                 </dl>

+ 2 - 2
src/client/components/kanban/KanbanTaskDrawer.vue

@@ -165,13 +165,13 @@ function metadataText(value: unknown): string {
           <article v-for="item in detail.comments" :key="item.id"><header><strong>{{ item.author }}</strong><time>{{ formatKanbanTime(item.created_at) }}</time></header><p>{{ item.body }}</p></article>
         </div>
         <p v-else class="kanban-empty-copy">还没有评论。</p>
-        <form v-if="canEdit" class="kanban-comment-form" @submit.prevent="sendComment"><textarea v-model="comment" rows="3" placeholder="补充上下文或给 Agent 留言" /><button type="submit" :disabled="busy || !comment.trim()">添加评论</button></form>
+        <form v-if="canEdit" class="kanban-comment-form" @submit.prevent="sendComment"><textarea v-model="comment" rows="3" placeholder="补充上下文或给机器人留言" /><button type="submit" :disabled="busy || !comment.trim()">添加评论</button></form>
       </section>
 
       <section class="kanban-detail-section">
         <h3>运行记录 <span>{{ detail.runs.length }}</span></h3>
         <div v-if="detail.runs.length" class="kanban-runs">
-          <article v-for="run in detail.runs" :key="run.id"><header><strong>{{ run.profile || '未指定 Agent' }}</strong><span>{{ run.outcome || run.status }}</span></header><p v-if="run.summary">{{ run.summary }}</p><p v-if="run.error" class="run-error">{{ run.error }}</p><pre v-if="metadataText(run.metadata)">{{ metadataText(run.metadata) }}</pre><time>{{ formatKanbanTime(run.started_at) }}<template v-if="run.ended_at"> → {{ formatKanbanTime(run.ended_at) }}</template></time></article>
+          <article v-for="run in detail.runs" :key="run.id"><header><strong>{{ run.profile || '未指定机器人' }}</strong><span>{{ run.outcome || run.status }}</span></header><p v-if="run.summary">{{ run.summary }}</p><p v-if="run.error" class="run-error">{{ run.error }}</p><pre v-if="metadataText(run.metadata)">{{ metadataText(run.metadata) }}</pre><time>{{ formatKanbanTime(run.started_at) }}<template v-if="run.ended_at"> → {{ formatKanbanTime(run.ended_at) }}</template></time></article>
         </div>
         <p v-else class="kanban-empty-copy">尚未产生运行记录。</p>
       </section>

+ 4 - 4
src/client/components/kanban/presentation.ts

@@ -4,10 +4,10 @@ export const KANBAN_STATUS_META: Record<string, { label: string; color: string;
   triage: { label: '待分诊', color: '#8b8b86', description: '需要补充或拆解的任务' },
   todo: { label: '待办', color: '#68727e', description: '已明确但尚未进入执行队列' },
   scheduled: { label: '已计划', color: '#8b5cf6', description: '由系统在指定时间唤醒' },
-  ready: { label: '就绪', color: '#1677ff', description: '等待调度器分配 Agent' },
-  running: { label: '进行中', color: '#1a9a68', description: 'Agent 正在处理' },
+  ready: { label: '就绪', color: '#1677ff', description: '等待调度器分配机器人' },
+  running: { label: '进行中', color: '#1a9a68', description: '机器人正在处理' },
   blocked: { label: '已阻塞', color: '#d44848', description: '等待外部输入或恢复' },
-  review: { label: '评审中', color: '#c58a15', description: '等待人工或 Agent 评审' },
+  review: { label: '评审中', color: '#c58a15', description: '等待人工或机器人评审' },
   done: { label: '已完成', color: '#66756d', description: '已经完成的任务' },
   archived: { label: '已归档', color: '#999994', description: '已从活动看板移除' },
 }
@@ -53,7 +53,7 @@ export function eventDescription(event: KanbanEvent): string {
   if (event.kind === 'status') return `移动到 ${statusMeta(text('status')).label}`
   if (event.kind === 'assigned') return text('assignee') ? `分配给 ${text('assignee')}` : '取消分配'
   if (event.kind === 'commented') return `添加评论${text('author') ? ` · ${text('author')}` : ''}`
-  if (event.kind === 'claimed' || event.kind === 'spawned') return 'Agent 开始处理'
+  if (event.kind === 'claimed' || event.kind === 'spawned') return '机器人开始处理'
   if (event.kind === 'completed') return '任务完成'
   if (event.kind === 'blocked') return `任务阻塞${text('reason') ? ` · ${text('reason')}` : ''}`
   if (event.kind === 'unblocked') return '任务已恢复'

+ 1 - 1
src/client/components/messages/MessageTimeline.vue

@@ -307,7 +307,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             </template>
             <template v-else>
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
-              <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
+              <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>

+ 7 - 6
src/client/components/workspace/ComputerPanel.vue

@@ -13,7 +13,7 @@ const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.va
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
-const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'机器人正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还机器人',error:'电脑需要检查'}
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
 const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
 const controllable=computed(()=>canSend.value&&!busy.value)
@@ -85,7 +85,7 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
   <Teleport to="body" :disabled="embedded">
     <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
       <header>
-        <div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
+        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':'隔离电脑'}}</strong><small>{{ labels[state.mode] }}</small></div>
         <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
         <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
         <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
@@ -98,10 +98,11 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
-      <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
+      <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
       <footer v-if="!embedded||controlsOpen">
+        <p v-if="state.backend==='grok'">多个机器人共享同一台 Grok Bot 云端电脑、工作文件与浏览器资料。</p>
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
-        <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
+        <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
         <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
           <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
@@ -111,8 +112,8 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
           <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
           <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
           </section>
-          <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉 Agent 你完成了什么,以及接下来需要做什么" /></label>
-          <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
+          <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉机器人你完成了什么,以及接下来需要做什么" /></label>
+          <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还机器人并继续' : '结束控制' }}</button>
           <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
         </template>
       </footer>

+ 1 - 1
src/client/components/workspace/ConversationList.vue

@@ -26,7 +26,7 @@ function action(kind: 'pin' | 'archive' | 'delete') {
 <template>
   <div class="conversation-list">
     <ResourceSidebar :items="rows" :active-id="selected" :searchable="false" :avatar-size="44" external-search
-      empty-title="还没有聊天" empty-description="创建 Agent,或选择成员新建群聊。"
+      empty-title="还没有聊天" empty-description="创建机器人,或选择成员新建群聊。"
       @select="emit('select', $event)" @more="openMenu" @context-menu="openMenu" />
     <Teleport to="body">
       <div v-if="menuConversation" class="conversation-menu-dismiss" @pointerdown.self="menuId = ''" @keydown.esc="menuId = ''">

+ 56 - 22
src/client/components/workspace/LocalVmChatPanel.vue

@@ -5,15 +5,20 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
 import type {LocalVmInstance,LocalVmAction} from '@shared/localVm'
-const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean}>()
+const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean;embedded?:boolean}>()
 const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
 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 computerTabs=ref<HTMLElement>()
-const computerChoice=computed(()=>state.value?.fixedCapacity?(state.value.desktopId??'off'):state.value?.enabled?'vm':'off')
+const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),token=ref(''),version=ref('0.47.0'),connectionSettings=ref(false)
+const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&agent.value.execution!=='computer'&&cloud.value?.configured))
+const computerChoice=computed(()=>state.value?.fixedCapacity?(state.value.desktopId??'off'):agent.value?.computer??(state.value?.enabled?'vm':'off'))
 const computerChoices=computed(()=>[
- {value:'off',label:'不使用电脑',disabled:false},
- ...(state.value?.fixedCapacity?(state.value.desktops??[]).map(desktop=>({value:desktop.id,label:desktop.name+(desktop.ready?'':' · 未就绪'),disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',disabled:false}]),
+ {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.ready?'已有共享桌面':'桌面尚未就绪',icon:'monitor' as const,disabled:desktop.available===false})):[{value:'vm',label:'本地虚拟机',description:'在隔离的本地桌面中工作',icon:'monitor' as const,disabled:false}]),
+ {value:'local',label:'本机',description:'当前节点未提供本机控制',icon:'monitor' as const,disabled:true},
+ {value:'browser',label:'仅浏览器',description:'尚未连接浏览器',icon:'globe' as const,disabled:true},
+ {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)
 function selectComputer(value:string){
@@ -34,62 +39,87 @@ async function refresh(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=revision
  try{
+  const cloudState=await apiRequest<typeof cloud.value>(`/api/app/agents/${selected.value}/cloud-computer`);if(version!==revision||closed)return;cloud.value=cloudState
+  if(cloudSelected.value){
+   state.value={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(state.value.ready){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}else frame.value=undefined
+   error.value='';return
+  }
   const result=await apiRequest<typeof state.value>(base());if(version!==revision||closed)return;state.value=result
   if(result?.container==='running'&&result.ready){const image=await apiRequest<ComputerFrame>(`/api/app/agents/${selected.value}/computer/frame`);if(version===revision&&!closed)frame.value=image}
   else frame.value=undefined
+  error.value=''
  }catch(e){if(version===revision)error.value=e instanceof Error?e.message:'无法读取电脑画面'}finally{if(version===revision)loading.value=false}
 }
 async function run(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}}
 function choose(value:string){
- if(state.value?.fixedCapacity&&value!=='off'&&value!==state.value.desktopId&&!confirm('连接这台共享桌面?同一桌面的成员会共用文件和浏览器登录。'))return
- void run(()=>apiRequest(base(),{method:'PUT',body:state.value?.fixedCapacity?{enabled:value!=='off',...(value!=='off'?{desktopId:value}:{})}:{enabled:value==='vm'}}))
+ if(state.value?.fixedCapacity&&!['off','auto','cloud'].includes(value)){
+   void run(()=>apiRequest(base(),{method:'PUT',body:{enabled:true,desktopId:value}}));return
+ }
+ void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:value}}))
 }
+async function connectCloud(importLocal=false){await run(async()=>{await apiRequest('/api/app/grok-cloud/connect',{method:'POST',body:{version:version.value,...(importLocal?{importLocal:true}:{accessToken:token.value})}});token.value='';connectionSettings.value=false})}
+async function openDesktop(){if(!agent.value)return;if(cloudSelected.value){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}emit('desktop',agent.value)}
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
  void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
 }
-async function cycle(){await refresh();if(!closed)timer=setTimeout(cycle,3000)}
+async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
 watch(()=>props.agents.map(a=>a.id).join(','),()=>{if(!props.agents.some(a=>a.id===selected.value))selected.value=props.agents[0]?.id??''},{immediate:true})
-watch(selected,()=>{revision++;state.value=undefined;frame.value=undefined;error.value='';loading.value=true;clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;state.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 </script>
 <template>
- <aside class="local-vm-chat-panel" aria-label="Agent 电脑面板">
+ <aside class="local-vm-chat-panel" :class="{embedded}" aria-label="机器人电脑面板">
   <header><strong>电脑</strong><button aria-label="关闭电脑面板" @click="emit('close')"><AppIcon name="close"/></button></header>
   <div class="panel-body">
-   <label v-if="agents.length>1" class="agent-select">Agent<select v-model="selected" :disabled="busy"><option v-for="item in agents" :key="item.id" :value="item.id">{{item.name}}</option></select></label>
-   <p v-if="!agent">当前聊天没有可用的 Agent。</p>
+   <label v-if="agents.length>1" class="agent-select">机器人<select v-model="selected" :disabled="busy"><option v-for="item in agents" :key="item.id" :value="item.id">{{item.name}}</option></select></label>
+   <p v-if="!agent">当前聊天没有可用的机器人。</p>
    <template v-else>
-    <div class="screen-caption"><span>{{agent.name}}的电脑</span><small v-if="state?.enabled">本地虚拟机</small></div>
-    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="emit('desktop',agent)">
+    <div class="screen-caption"><span>{{agent.name}}的电脑</span><small v-if="state?.enabled">{{cloudSelected?'Grok Bot 云端':'本地虚拟机'}}</small></div>
+    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openDesktop">
      <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${agent.name}的电脑画面`">
-     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':!state?.enabled?'未使用电脑':!state.image?'本地虚拟机尚未准备':state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':'正在连接桌面…'}}</span></span>
+     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':!state?.enabled?(computerChoice==='auto'?'暂无可用电脑':'未使用电脑'):!state.image?(cloudSelected?'尚未连接 Grok Bot 账号':'本地虚拟机尚未准备'):state.container==='missing'?'尚未创建虚拟机':state.container==='stopped'?'虚拟机已停止':cloudSelected?'打开云端电脑后可查看桌面':'正在连接桌面…'}}</span></span>
      <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>此 Agent 使用的电脑</span>
-     <div ref="computerTabs" class="computer-tabs" role="tablist" aria-label="此 Agent 使用的电脑" @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" :disabled="computerChoiceDisabled||option.disabled" @click="selectComputer(option.value)">{{option.label}}</button>
+     <span>此机器人使用的电脑</span>
+     <div 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}`">
-    <template v-if="state?.enabled">
-     <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此 Agent 的独立虚拟机'}}</p>
+    <p v-if="computerChoice==='auto'" class="hint">按已有可用环境自动选择。查看此面板不会创建或唤醒云端电脑。</p>
+    <template v-if="cloudSelected">
+     <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
+     <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
+     <button v-if="isAdmin" @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
+     <p v-else-if="!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
+    </template>
+    <template v-else-if="state?.enabled">
+     <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
      <button v-if="!state.image" class="primary" :disabled="!isAdmin" @click="emit('settings')">设置本地虚拟机</button>
      <button v-else-if="!state.fixedCapacity&&state.container==='missing'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('create')">创建 {{agent.name}} 的虚拟机</button>
      <button v-else-if="!state.fixedCapacity&&state.container==='stopped'&&!agent.temporaryGoalId" class="primary" :disabled="busy" @click="action('start')">启动虚拟机</button>
      <template v-else-if="state.container==='running'">
-      <button class="primary" :disabled="!state.ready||busy" @click="emit('desktop',agent)">打开桌面</button>
+      <button class="primary" :disabled="!state.ready||busy" @click="openDesktop">打开桌面</button>
       <button v-if="state.mode==='per-bot'" :disabled="busy" @click="emit('workspace',agent)"><AppIcon name="panel"/>打开双桌面</button>
-      <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'Agent 正在操作':'仅查看 · 虚拟机空闲'}}</p>
+      <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'机器人正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
      <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button></div>
      <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':'重建会保留工作目录和浏览器资料。虚拟机空闲 5 分钟后自动停止。'}}</p>
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>
     </div>
+    <button v-if="!cloudSelected&&isAdmin" class="settings-link" @click="connectionSettings=!connectionSettings"><AppIcon name="globe" :size="14"/>Grok Bot 云端连接</button>
+    <div v-if="connectionSettings&&isAdmin" class="cloud-settings">
+     <label>Grok Bot 版本<input v-model="version" autocomplete="off"></label>
+     <label>登录凭据<input v-model="token" type="password" autocomplete="off" placeholder="仅保存在服务器"></label>
+     <button :disabled="busy||!token" @click="connectCloud()">连接云端账号</button>
+     <button :disabled="busy" @click="connectCloud(true)">使用服务器上的 Grok Bot 登录</button>
+    </div>
    </template>
   </div>
  </aside>
@@ -105,3 +135,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 .computer-options{display:flex;flex-direction:column;gap:14px}
 .computer-options:empty{display:none}
 </style>
+
+<style scoped>.cloud-settings{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:10px}.cloud-settings input{min-height:44px;font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;padding:8px;min-width:0}.computer-tabs{display:grid;grid-template-columns:1fr 1fr;overflow:visible}.computer-tabs button{text-align:left;justify-content:flex-start;white-space:normal;min-height:48px}</style>
+
+<style scoped>.computer-tabs{gap:8px;border:0;padding:0;background:transparent}.computer-tabs button{display:flex;flex-direction:column;align-items:flex-start;gap:7px;padding:12px;border:1px solid var(--line);background:var(--surface-soft);min-height:82px}.computer-tabs button span{display:flex;align-items:center;gap:6px;font-weight:500}.computer-tabs button small{font-size:11px;line-height:1.5;color:var(--text-muted)}.computer-tabs button[aria-selected=true]{border-color:var(--accent);background:var(--surface-raised);box-shadow:none}.computer-tabs button:disabled{opacity:.55}</style>

+ 3 - 3
src/client/components/workspace/LocalVmWorkspace.vue

@@ -18,12 +18,12 @@ defineExpose({close})
 </script>
 <template>
  <main class="local-vm-workspace" aria-label="双桌面工作区">
-  <header><div><strong>本地虚拟机 · 双桌面</strong><p>同时查看两个 Agent 的桌面,每次只控制其中一台。</p></div><button :disabled="busy" @click="close">交还并关闭</button></header>
+  <header><div><strong>本地虚拟机 · 双桌面</strong><p>同时查看两个机器人的桌面,每次只控制其中一台。</p></div><button :disabled="busy" @click="close">交还并关闭</button></header>
   <p v-if="error" role="alert">{{error}}</p>
   <div class="panes"><section v-for="(_,index) in slots" :key="index" class="pane" :class="{controlled:controlled===index}">
-   <label>桌面 {{index+1}}<select :value="slots[index]" :disabled="busy" :aria-label="`选择桌面 ${index+1}`" @change="select(index,($event.target as HTMLSelectElement).value)"><option value="">选择 Agent</option><option v-for="agent in choices" :key="agent.id" :value="agent.id" :disabled="slots[1-index]===agent.id">{{agent.name}}</option></select></label>
+   <label>桌面 {{index+1}}<select :value="slots[index]" :disabled="busy" :aria-label="`选择桌面 ${index+1}`" @change="select(index,($event.target as HTMLSelectElement).value)"><option value="">选择机器人</option><option v-for="agent in choices" :key="agent.id" :value="agent.id" :disabled="slots[1-index]===agent.id">{{agent.name}}</option></select></label>
    <ComputerPanel v-if="slots[index]&&choices.some(a=>a.id===slots[index])" :key="slots[index]" :ref="el=>panels[index]=el as InstanceType<typeof ComputerPanel>|null" embedded :agents="choices.filter(a=>a.id===slots[index])" @control-request="take(index)"/>
-   <p v-else class="empty">选择另一位 Agent。打开工作区不会创建或启动虚拟机。</p>
+   <p v-else class="empty">选择另一位机器人。打开工作区不会创建或启动虚拟机。</p>
   </section></div>
  </main>
 </template>

+ 3 - 3
src/client/components/workspace/RemoteAgentPicker.vue

@@ -25,12 +25,12 @@ async function add(agent:WorkspaceAgent){
 </script>
 <template>
   <dialog ref="dialog" class="remote-agent-picker" @cancel.prevent="!adding && emit('close')">
-    <header><h2>添加远程 Agent</h2><button type="button" :disabled="adding" @click="emit('close')">关闭</button></header>
-    <p>直接引用远端 Bot 模式的 Agent,配置由远端管理,可用于聊天和群聊。</p>
+    <header><h2>添加远程机器人</h2><button type="button" :disabled="adding" @click="emit('close')">关闭</button></header>
+    <p>直接引用远端 Bot 模式的机器人,配置由远端管理,可用于聊天和群聊。</p>
     <label>远程子节点<select v-model="nodeId" :disabled="busy"><option v-for="node in nodes" :key="node.id" :value="node.id">{{node.name}}</option></select></label>
     <p v-if="error" role="alert">{{error}}</p>
     <p v-if="!nodes.length">请先在远程节点中扫码添加子节点。</p>
-    <p v-else-if="!busy && !agents.length && !error">这个节点暂无可添加的 Agent。</p>
+    <p v-else-if="!busy && !agents.length && !error">这个节点暂无可添加的机器人。</p>
     <button v-for="agent in agents" :key="agent.id" class="agent-option" :disabled="busy" @click="add(agent)">{{agent.name}}<span>添加</span></button>
     <p v-if="busy">加载中…</p>
   </dialog>

+ 3 - 3
src/client/components/workspace/TeamPresetPicker.vue

@@ -6,14 +6,14 @@ const shortage = (preset: TeamPreset) => Math.max(0, preset.roles.length - props
 </script>
 <template>
   <section class="preset-picker" aria-label="团队预设">
-    <div class="preset-picker__heading"><span>团队预设</span><small>选择后分配已创建的 Agent</small></div>
+    <div class="preset-picker__heading"><span>团队预设</span><small>选择后分配已创建的机器人</small></div>
     <div class="preset-grid">
-      <button type="button" :class="{ selected: selected === 'custom' }" @click="emit('select')"><strong>自定义团队</strong><small>手动选择 Agent 和协作配置</small><em>自由配置</em></button>
+      <button type="button" :class="{ selected: selected === 'custom' }" @click="emit('select')"><strong>自定义团队</strong><small>手动选择机器人和协作配置</small><em>自由配置</em></button>
       <button v-for="preset in TEAM_PRESETS" :key="preset.id" type="button" :class="{ selected: selected === preset.id }" :disabled="shortage(preset) > 0" :aria-label="preset.name" @click="emit('select', preset)">
         <strong>{{ preset.name }}</strong><small>{{ preset.summary }}</small><em>{{ shortage(preset) ? `还缺 ${shortage(preset)} 人` : `${preset.roles.length} 人` }}</em>
       </button>
     </div>
-    <small v-if="available < 5" class="preset-help">成员不足时,请先创建所需的 Agent。</small>
+    <small v-if="available < 5" class="preset-help">成员不足时,请先创建所需的机器人。</small>
   </section>
 </template>
 <style scoped>

+ 24 - 0
src/client/components/workspace/WorkspaceBotPanel.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import {ref,watch} from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import LocalVmChatPanel from './LocalVmChatPanel.vue'
+import WorkspaceInspectorPanel from './WorkspaceInspectorPanel.vue'
+import WorkspaceRoutinesPanel from './WorkspaceRoutinesPanel.vue'
+import type {WorkspaceAgent} from '@shared/workspace'
+const props=defineProps<{agents:WorkspaceAgent[];conversationId:string;taskId?:string;group:boolean;isAdmin:boolean;active?:boolean;initialView?:'computer'|'routines'|'inspector'}>()
+const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
+const view=ref<'computer'|'routines'|'inspector'>('computer')
+watch(()=>[props.conversationId,props.initialView],()=>{let saved;try{saved=localStorage.getItem('yaoyao-bot-panel:'+props.conversationId)}catch{}view.value=props.group?'inspector':props.initialView??(['computer','routines','inspector'].includes(saved??'')?saved as typeof view.value:'computer')},{immediate:true})
+watch(view,value=>{try{localStorage.setItem('yaoyao-bot-panel:'+props.conversationId,value)}catch{}})
+</script>
+<template>
+ <aside class="bot-panel" aria-label="机器人右侧栏">
+  <header><div class="tabs" role="group" aria-label="机器人侧栏项目"><button v-if="!group" :aria-pressed="view==='computer'" @click="view='computer'"><AppIcon name="monitor" :size="15"/>电脑</button><button v-if="!group" :aria-pressed="view==='routines'" @click="view='routines'"><AppIcon name="calendar" :size="15"/>定时任务</button><button :aria-pressed="view==='inspector'" @click="view='inspector'"><AppIcon name="bug" :size="15"/>Inspector</button></div><button aria-label="关闭机器人侧栏" @click="emit('close')"><AppIcon name="close"/></button></header>
+  <LocalVmChatPanel v-if="view==='computer'&&!group" embedded :agents="agents" :is-admin="isAdmin" :active="active" @changed="emit('changed')" @settings="emit('settings')" @desktop="emit('desktop',$event)" @workspace="emit('workspace',$event)"/>
+  <WorkspaceRoutinesPanel v-else-if="view==='routines'&&agents[0]&&!group" :agent-id="agents[0].id"/>
+  <WorkspaceInspectorPanel v-else :conversation-id="conversationId" :task-id="taskId"/>
+ </aside>
+</template>
+<style scoped>
+.bot-panel{width:420px;min-width:320px;max-width:46%;height:100%;border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;color:var(--text-primary)}header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.tabs{display:flex;flex-wrap:wrap;gap:2px}.tabs button{font-size:12px;padding:8px}.tabs button[aria-pressed=true]{background:var(--surface-soft);color:var(--text-primary);font-weight:600}button{font:inherit;display:inline-flex;align-items:center;justify-content:center;gap:5px;border:0;background:transparent;color:var(--text-secondary);border-radius:8px;min-width:44px;min-height:44px;cursor:pointer}button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(max-width:900px){.bot-panel{position:fixed;inset:0 0 0 auto;width:min(440px,100vw);max-width:100%;z-index:35;box-shadow:-20px 0 60px #0002}}:deep(.local-vm-chat-panel.embedded){width:100%;max-width:none;min-width:0;border:0;position:static;box-shadow:none;flex:1;min-height:0}:deep(.local-vm-chat-panel.embedded>header){display:none}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 45 - 0
src/client/components/workspace/WorkspaceInspectorPanel.vue


+ 1 - 1
src/client/components/workspace/WorkspaceNodesPanel.vue

@@ -34,7 +34,7 @@ onMounted(load)
 </script>
 <template>
   <section class="nodes-panel">
-    <p>远程节点连接 15300 夭夭 Web,仅作为当前服务器的子节点。连接后可用它的基础智能体创建自己的 Agent 并加入群聊。</p>
+    <p>远程节点连接 15300 夭夭 Web,仅作为当前服务器的子节点。连接后可用它的基础智能体创建自己的机器人并加入群聊。</p>
     <p v-if="error" role="alert">{{ error }}</p>
     <article v-for="node in nodes" :key="node.id">
       <div><strong>{{ node.name }}</strong><small>{{ node.url }} · 子节点</small></div>

Datei-Diff unterdrückt, da er zu groß ist
+ 65 - 0
src/client/components/workspace/WorkspaceRoutinesPanel.vue


+ 1 - 1
src/client/components/workspace/WorkspaceView.vue

@@ -37,7 +37,7 @@ async function saveIdentity(input: ProfileIdentityInput) {
     identityError.value = ''
     identityResetVersion.value += 1
   } catch (cause) {
-    identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
+    identityError.value = cause instanceof Error ? cause.message : '保存机器人身份失败'
   } finally {
     identityBusy.value = false
   }

+ 3 - 3
src/client/components/workspace/viewModels.ts

@@ -192,7 +192,7 @@ export function chatInteraction(approval?: ApprovalRequest, clarification?: Clar
     id: approval.id,
     kind: 'approval',
     title: approval.toolName ? `允许 ${approval.toolName}` : '允许这项操作',
-    prompt: approval.message || 'Agent 请求执行一项需要确认的操作。',
+    prompt: approval.message || '机器人请求执行一项需要确认的操作。',
     options: approval.choices,
     detail: JSON.stringify(approval.payload, null, 2),
   }
@@ -214,7 +214,7 @@ export function roomSidebarItem(
   return {
     id: room.id,
     title: room.name || '未命名团队',
-    subtitle: room.lastMessage?.content || `${room.agentCount} 个 Agent`,
+    subtitle: room.lastMessage?.content || `${room.agentCount} 个机器人`,
     meta: formatRelative(room.updatedAt),
     section: historySection(room.updatedAt),
     unread: room.unreadCount,
@@ -286,7 +286,7 @@ export function groupInteraction(interaction?: GroupInteraction): UiInteraction
     id: interaction.id,
     kind: interaction.kind === 'clarification' ? 'clarification' : 'approval',
     title: string(payload.title || payload.tool_name),
-    prompt: string(payload.question || payload.message || payload.prompt, interaction.kind === 'clarification' ? 'Agent 需要补充信息' : 'Agent 请求执行操作'),
+    prompt: string(payload.question || payload.message || payload.prompt, interaction.kind === 'clarification' ? '机器人需要补充信息' : '机器人请求执行操作'),
     options: choices,
     detail: JSON.stringify(payload, null, 2),
   }

+ 2 - 2
src/client/views/ArtifactsView.vue

@@ -79,14 +79,14 @@ onBeforeUnmount(() => window.clearTimeout(searchTimer))
       />
     </template>
     <section class="library-workspace">
-      <header class="library-header"><div><h2>产物</h2><p>从普通会话的 Agent 与工具消息中提取</p></div><button class="quiet-button" type="button" :disabled="artifacts.isLoading" @click="artifacts.refresh"><AppIcon name="refresh" :size="14" />刷新索引</button></header>
+      <header class="library-header"><div><h2>产物</h2><p>从普通会话的机器人与工具消息中提取</p></div><button class="quiet-button" type="button" :disabled="artifacts.isLoading" @click="artifacts.refresh"><AppIcon name="refresh" :size="14" />刷新索引</button></header>
       <LibraryGrid
         kind="artifacts"
         :items="items"
         :selected-id="selected?.id"
         :loading="artifacts.isLoading"
         empty-title="还没有产物"
-        empty-description="Agent 生成的图片、文件和链接会自动整理到这里。"
+        empty-description="机器人生成的图片、文件和链接会自动整理到这里。"
         @select="selected = $event"
         @add-to-composer="addToComposer"
         @source="openSource"

+ 22 - 21
src/client/views/ConversationsView.vue

@@ -6,7 +6,7 @@ import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vu
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
-import LocalVmChatPanel from '@/components/workspace/LocalVmChatPanel.vue'
+import WorkspaceBotPanel from '@/components/workspace/WorkspaceBotPanel.vue'
 import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
@@ -90,7 +90,7 @@ const uiMessages = computed(() => workspaceMessagesToUi(messages.value))
 const media = computed(() => mediaItemsFromMessages(uiMessages.value))
 const lightboxMedia = computed(() => media.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })))
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
-const avatarProfile = computed(() => ({ name: editingId.value || 'Agent', displayName: form.name || 'Agent', agentName: form.name || 'Agent', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
+const avatarProfile = computed(() => ({ name: editingId.value || '机器人', displayName: form.name || '机器人', agentName: form.name || '机器人', agentAvatar: form.avatar, isDefault: false, isRunning: true }))
 function openPreview(file: {name: string; url?: string; kind?: string}) {
   let path = file.url || ''
   try { path = decodeURIComponent(new URL(path, window.location.origin).pathname) } catch { /* preserve the supplied URL */ }
@@ -213,7 +213,7 @@ async function saveProfileIdentity(input: ProfileIdentityInput) {
     await auth.refreshProfileAvatars()
     identityResetVersion.value += 1
   } catch (cause) {
-    identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
+    identityError.value = cause instanceof Error ? cause.message : '保存机器人身份失败'
   } finally {
     identityBusy.value = false
   }
@@ -377,7 +377,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
   selectedPresetId.value = 'custom'
   Object.assign(form, {
     name: '',
-    avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
+    avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', '机器人')) : '',
     instructions: '',
     execution: 'profile' as 'profile'|'computer',
   canManageTeam: false,
@@ -390,7 +390,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
     maxReplyRounds: 3,
   })
   try {
-    if (kind === 'agent') {
+    if (kind === 'agent' || kind === 'editAgent') {
       const s = await apiRequest<{ sources: Source[] }>('/api/app/agents/sources')
       sources.value = s.sources
       form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
@@ -400,6 +400,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
       if (!a) return
       editingId.value = a.id
       Object.assign(form, a)
+      form.source=JSON.stringify([a.nodeId,a.profile])
       if(a.remoteAgentId){
         const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
         if(remote)Object.assign(form,remote)
@@ -442,7 +443,7 @@ async function save() {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
         method: 'POST',
-        body: { ...fields, nodeId, profile },
+        body: { ...fields, nodeId, profile, computer: 'auto' },
       })
       await refresh()
       const c = conversations.value.find(
@@ -450,7 +451,7 @@ async function save() {
       )
       if (c) await select(c.id)
     } else if (dialog.value === 'editAgent')
-      await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: fields })
+      await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { ...fields, ...(form.source?{nodeId:JSON.parse(form.source)[0],profile:JSON.parse(form.source)[1]}:{}) } })
     else {
       const payload = {
         ...fields,
@@ -706,7 +707,7 @@ onBeforeUnmount(() => {
         :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
         :mention-names="members.map(a => a.name)"
         :empty-title="active ? '开始一段新对话' : '还没有聊天'"
-        :empty-description="active ? '从下方输入框发送消息。' : '创建 Agent,或选择成员新建群聊。'"
+        :empty-description="active ? '从下方输入框发送消息。' : '创建机器人,或选择成员新建群聊。'"
         :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
         @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
@@ -714,7 +715,7 @@ onBeforeUnmount(() => {
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
           <div v-if="active" class="header-actions">
-            <button type="button" class="icon-button" aria-label="电脑" title="电脑" @click="computerDockOpen=!computerDockOpen"><AppIcon name="monitor"/></button>
+            <button type="button" class="icon-button" :aria-label="active.kind==='group'?'Inspector':'电脑与定时任务'" :title="active.kind==='group'?'Inspector':'电脑与定时任务'" @click="computerDockOpen=!computerDockOpen"><AppIcon :name="active.kind==='group'?'tools':'monitor'"/></button>
             <select v-if="active.kind === 'group' && tasks.length" class="task-picker" aria-label="当前任务" :value="activeTask?.id" @change="router.push({query:{...route.query,taskId:($event.target as HTMLSelectElement).value}})">
               <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
             </select>
@@ -722,7 +723,7 @@ onBeforeUnmount(() => {
           </div>
         </template>
       </MessageTimeline>
-      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用 Agent</p>
+      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
       <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
       <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
@@ -731,7 +732,7 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
-    <LocalVmChatPanel v-if="computerDockOpen" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
+    <WorkspaceBotPanel v-if="computerDockOpen&&active" :conversation-id="active.id" :task-id="activeTask?.id" :group="active.kind==='group'" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
     </div>
     <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
     <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
@@ -754,22 +755,22 @@ onBeforeUnmount(() => {
       <form @submit.prevent="save">
         <header>
           <h2>
-            {{ dialog === 'agent' ? '创建 Agent' : dialog === 'group' ? '创建群聊' : '编辑资料' }}
+            {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : '编辑资料' }}
           </h2>
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
         <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
-        <p v-if="isRemoteAgent">引用的 Agent 配置由远端管理,请在远端修改名称、头像和角色规则。</p>
+        <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
         <div v-else-if="!isAgentDialog" class="team-avatar-settings">
           <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
           <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
         </div>
         <label
-          v-if="dialog === 'agent'"
-          >基础 Agent<select v-model="form.source" required>
+          v-if="isAgentDialog&&!isRemoteAgent"
+          >基础机器人<select v-model="form.source" required>
             <option
               v-for="s in sources"
               :key="`${s.nodeId}:${s.profile}`"
@@ -778,7 +779,7 @@ onBeforeUnmount(() => {
               {{ s.name }} · {{ s.nodeId === 'local' ? '当前服务' : s.nodeId }}
             </option></select
           ><small v-if="!sources.length"
-            >{{ auth.isBotOnly ? '暂无可用的已分配 Agent,请联系管理员分配或检查连接。' : '没有可用的基础 Agent,请检查 Web 的 Hermes 连接。' }}</small
+            >{{ auth.isBotOnly ? '暂无可用的已分配机器人,请联系管理员分配或检查连接。' : '没有可用的基础机器人,请检查 Web 的 Hermes 连接。' }}</small
           ></label
         ><label
           >{{ isAgentDialog ? '角色提示词与规则' : '群规则'
@@ -796,19 +797,19 @@ onBeforeUnmount(() => {
         </label>
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
-          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
+          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础机器人,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
         </label>
-        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
+        <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程机器人</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
             {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
-            <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
+            <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的机器人`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
               <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
             </select>
             <small>{{ role.description }}</small>
           </label>
-          <small>角色分工仅在本群生效,聊天中保留 Agent 的名称和头像。</small>
+          <small>角色分工仅在本群生效,聊天中保留机器人的名称和头像。</small>
         </fieldset>
         <fieldset v-else-if="!isAgentDialog">
           <legend>选择成员</legend>
@@ -824,7 +825,7 @@ onBeforeUnmount(() => {
               :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
             />{{ a.name }}</label
           ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
-            >至少需要两个 Agent 才能创建群聊。</small
+            >至少需要两个机器人才能创建群聊。</small
           >
           <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
           <button v-for="a in agents.filter(a => active?.activeAgentStates?.[a.id])" :key="`stop:${a.id}`" type="button" @click="stopMember(a.id)">停止 {{ a.name }}</button>

+ 9 - 9
src/client/views/GroupsView.vue

@@ -160,7 +160,7 @@ function archivedRoomSidebarItems(): SidebarItemBase[] {
   return archivedRoomList.value.map(room => ({
   id: room.id,
   title: room.name || '未命名团队',
-  subtitle: room.lastMessage?.content || `${room.agentCount} 个 Agent`,
+  subtitle: room.lastMessage?.content || `${room.agentCount} 个机器人`,
   meta: sidebarDate(room.updatedAt),
   avatar: room.avatar || '',
   avatarFallbackKey: room.id,
@@ -283,7 +283,7 @@ const managerBusy = computed(() => groups.isLoading || Object.values(agentUpdate
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
 const mentionNames = computed(() => ['所有人', ...displayAgents.value.map(agent => agent.displayName || agent.profile)])
 const mentionOptions = computed<ComposerOption[]>(() => [
-  { id: 'all', label: '所有人', detail: '通知团队内全部 Agent' },
+  { id: 'all', label: '所有人', detail: '通知团队内全部机器人' },
   ...displayAgents.value.map(agent => ({
     id: agent.id,
     label: agent.displayName || agent.profile,
@@ -295,7 +295,7 @@ const roomSubtitle = computed(() => {
   if (!groups.selectedRoom) return '选择或新建一个团队'
   const host = hostAgent.value ? `管理员 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
   const mode = groups.selectedRoom.orchestrationMode === 'host' ? '管理员协调 · ' : ''
-  return `${groups.selectedRoom.name} · ${mode}${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
+  return `${groups.selectedRoom.name} · ${mode}${host}${groups.agents.length} 个机器人 · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
 })
 const activeAgentIds = computed(() => {
   if (!groups.topicProtocol) {
@@ -319,7 +319,7 @@ const typingActivity = computed(() => {
   const names = typingAgentNames.value
   if (!names.length) return ''
   if (names.length <= 3) return `${names.join('、')}正在输入…`
-  return `${names.slice(0, 2).join('、')}等 ${names.length} 个 Agent 正在输入…`
+  return `${names.slice(0, 2).join('、')}等 ${names.length} 个机器人正在输入…`
 })
 
 function groupRoute(roomId = groups.selectedRoomId, topicId = groups.selectedTopicId): string {
@@ -609,7 +609,7 @@ async function updateAgent(id: string, patch: AgentSettingsPatch) {
   agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
   try { await groups.updateAgent(groups.selectedRoom.id, id, patch) }
   catch (cause) {
-    const message = cause instanceof Error ? cause.message : 'Agent 设置保存失败'
+    const message = cause instanceof Error ? cause.message : '机器人设置保存失败'
     agentUpdateError.value = { ...agentUpdateError.value, [id]: message }
     managerError.value = message
   }
@@ -621,7 +621,7 @@ async function removeAgent(id: string) {
   managerError.value = ''
   agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
   try { await groups.removeAgent(groups.selectedRoom.id, id) }
-  catch (cause) { managerError.value = cause instanceof Error ? cause.message : '移除 Agent 失败' }
+  catch (cause) { managerError.value = cause instanceof Error ? cause.message : '移除机器人失败' }
   finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
 }
 
@@ -836,8 +836,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :interaction="activeInteraction"
         :mention-names="mentionNames"
         :agent-avatars="agentAvatars"
-        :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个 Agent 一起工作'"
-        :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留 Agent 上下文。' : '使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。'"
+        :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个机器人一起工作'"
+        :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留机器人上下文。' : '使用 @ 提及指定机器人,或直接发送消息触发已启用自动回复的成员。'"
         @load-older="groups.loadOlder"
         @quote="quoted = $event"
         @preview="openAttachment"
@@ -867,7 +867,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :reference="reference"
         :mention-options="mentionOptions"
         :attachments-enabled="uploadsEnabled"
-        placeholder="发消息给团队,输入 @ 提及 Agent"
+        placeholder="发消息给团队,输入 @ 提及机器人"
         @send="send"
         @stop="stopActiveTopic"
         @tool-trace-toggle="toggleShowThinking"

+ 2 - 2
src/client/views/KanbanView.vue

@@ -101,7 +101,7 @@ async function deleteTask(): Promise<void> {
 }
 
 async function dispatchNow(): Promise<void> {
-  if (!window.confirm('立即运行一次看板调度?就绪任务可能马上启动 Agent。')) return
+  if (!window.confirm('立即运行一次看板调度?就绪任务可能马上启动机器人。')) return
   try { await kanban.dispatch() } catch { /* store exposes the error */ }
 }
 
@@ -188,7 +188,7 @@ onBeforeUnmount(() => kanban.reset())
 
         <div class="kanban-filters">
           <label class="kanban-search"><AppIcon name="search" :size="14" /><input v-model="kanban.search" type="search" placeholder="搜索标题、描述或任务 ID" /></label>
-          <label><span>Agent</span><select v-model="kanban.assignee"><option value="">全部</option><option v-for="value in kanban.snapshot?.assignees || []" :key="value" :value="value">{{ value }}</option></select></label>
+          <label><span>机器人</span><select v-model="kanban.assignee"><option value="">全部</option><option v-for="value in kanban.snapshot?.assignees || []" :key="value" :value="value">{{ value }}</option></select></label>
           <label><span>租户</span><select v-model="kanban.tenant"><option value="">全部</option><option v-for="value in kanban.snapshot?.tenants || []" :key="value" :value="value">{{ value }}</option></select></label>
           <label class="kanban-archived-toggle"><input :checked="kanban.includeArchived" type="checkbox" @change="kanban.setIncludeArchived(($event.target as HTMLInputElement).checked)" /><span>显示归档</span></label>
           <span class="kanban-filter-count">{{ visibleTaskCount }} 项</span>

+ 1 - 1
src/runner/computers/localVm.ts

@@ -113,7 +113,7 @@ export class LocalVmImages {
           image=await this.images.prepare(recipe)
         }
         await check();this.state.imageId=image.imageId;this.runtime.config.imageId=image.imageId
-        this.state.job={id,state:'complete',message:'本地虚拟机已就绪,可在 Agent 的电脑面板中创建桌面'};this.save()
+        this.state.job={id,state:'complete',message:'本地虚拟机已就绪,可在机器人的电脑面板中创建桌面'};this.save()
       } catch(error) {
         this.state.job={id,state:'failed',message:error instanceof Error?error.message.slice(0,240):'本地虚拟机准备失败'};this.save()
       } finally {

+ 1 - 1
src/runner/computers/pool.ts

@@ -137,7 +137,7 @@ export class ComputerPool {
       if(!this.ready||this.closing||this.maintenance)throw new ComputerError('computer_busy','本地虚拟机正在维护')
       let entry=this.get(spec.id)
       if(entry?.spec.ownerKey!==undefined&&entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','虚拟机归属不匹配')
-      if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止 Agent 任务并交还控制权')
+      if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止机器人任务并交还控制权')
       const starts=['create','start','recreate'].includes(action)
       if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
       if(!entry&&!starts)return

+ 4 - 4
src/runner/worker/gateway.ts

@@ -105,7 +105,7 @@ export class ComputerRuntime {
       return
     }
     const resolved=await this.resolveWorkspace(profile);authorize()
-    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.config.imageId,cwd:resolved.cwd,network:this.config.network??'none'},action,authorize)
+    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.config.imageId,cwd:meta.environmentId!==meta.agentId?(this.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.config.network??'none'},action,authorize)
   }
 }
 export class ComputerGateway {
@@ -136,7 +136,7 @@ export class ComputerGateway {
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       await this.authorized();this.guard()
       const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
-      session.cwd=resolved.cwd;session.configuredCwd=resolved.configuredCwd
+      session.cwd=this.meta.environmentId!==this.meta.agentId?(this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd;session.configuredCwd=resolved.configuredCwd
       await this.runtime.pool.configure(this.spec(session),()=>this.guard())
       await this.authorized()
       const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
@@ -282,7 +282,7 @@ export class ComputerGateway {
   get specification(){return this.live?this.spec(this.live.session):undefined}
   async takeControl(controlId:string,authorize:()=>void):Promise<ComputerLease>{
     const live=this.live
-    if(!live||!this.active)throw new HttpError(409,'Agent 已不在执行,请重新打开电脑','computer_not_active')
+    if(!live||!this.active)throw new HttpError(409,'机器人已不在执行,请重新打开电脑','computer_not_active')
     if(live.pauseJob)return live.pauseJob
     live.paused=true;this.event('computer.paused',{stage:'pausing'})
     live.pauseJob=(async()=>{
@@ -304,7 +304,7 @@ export class ComputerGateway {
   }
   async giveBack(notes:string):Promise<void>{
     const live=this.live
-    if(!live?.paused)throw new HttpError(409,'Agent 没有处于接管状态','computer_not_paused')
+    if(!live?.paused)throw new HttpError(409,'机器人没有处于接管状态','computer_not_paused')
     await live.pauseJob;await this.authorized()
     live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
     live.paused=false;live.pauseJob=undefined;live.journal.clear()

+ 2 - 2
src/runner/worker/process.ts

@@ -11,9 +11,9 @@ const configurationErrors:Record<string,string>={
   computer_profile_missing:'未找到基础 Profile 配置,请检查执行节点上的 Hermes Profile',
   computer_profile_config_invalid:'基础 Profile 配置无法读取,请检查 config.yaml',
   computer_cwd_invalid:'基础 Profile 的 terminal.cwd 无效,请使用工作区内的相对路径或有效绝对路径',
-  computer_model_missing:'基础 Profile 尚未配置模型,请先选择模型后再运行 Agent',
+  computer_model_missing:'基础 Profile 尚未配置模型,请先选择模型后再运行机器人',
   computer_model_unavailable:'基础 Profile 的模型配置无法使用,请检查 Provider 与认证信息',
-  computer_model_unsupported:'基础 Profile 的模型接口暂不支持隔离电脑 Agent',
+  computer_model_unsupported:'基础 Profile 的模型接口暂不支持隔离电脑机器人',
 }
 export class HermesWorkerProcess {
   readonly nonce=randomUUID()

+ 13 - 0
src/server/app.ts

@@ -1,3 +1,6 @@
+import {GrokCloud} from './grokCloud.js'
+import {WorkspaceInspector} from './workspaceInspector.js'
+import {WorkspaceRoutines} from './workspaceRoutines.js'
 import { nativeMessageFileText } from '../shared/messageFiles.js'
 import { hermesBotRelay } from './hermesBotRelay.js'
 import { remoteAgentExports } from './workspaceRemoteAgents.js'
@@ -96,6 +99,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
+  workspaceRoutines:WorkspaceRoutines
   localVm: LocalVmService
   close(): void
 }
@@ -221,6 +225,11 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
+  const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers)
+  workspaceRuntime.cloud=grokCloud
+  const workspaceInspector=new WorkspaceInspector(workspace,auth)
+  workspaceRuntime.inspector=workspaceInspector
+  const workspaceRoutines=new WorkspaceRoutines(workspace,auth,workspaceNodes,workspaceRuntime)
   workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
@@ -393,6 +402,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
+  for(const router of [grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
@@ -458,6 +468,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     localVm,
     workspace,
     workspaceRuntime,
+    workspaceRoutines,
     realtime,
     app,
     config,
@@ -480,6 +491,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     close: () => {
       runners.close()
       workspaceAssets.close()
+      workspaceRoutines.close()
       workspaceRuntime.close()
       workspaceNodes.close()
       upstream.close()
@@ -515,6 +527,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   }
   server.once('listening',()=>{const address=server.address();if(address&&typeof address!=='string')void runtime.localVm.start(`${config.tlsCert?'https':'http'}://127.0.0.1:${address.port}`).catch(error=>console.error('本地虚拟机服务启动失败',error.message))})
   runtime.workspaceRuntime.start()
+  runtime.workspaceRoutines.start()
   synchronizePushObservers()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)

+ 2 - 2
src/server/computerControls.ts

@@ -13,7 +13,7 @@ export class ComputerControlService {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
   private agent(owner:string,id:string){
     const agent=this.store.require<WorkspaceAgent>(owner,'agent',id)
-    if(agent.archived||agent.execution!=='computer'||agent.remoteAgentId)throw new HttpError(409,'该 Agent 没有可用的隔离电脑','computer_unavailable')
+    if(agent.archived||agent.execution!=='computer'||agent.remoteAgentId)throw new HttpError(409,'该机器人没有可用的隔离电脑','computer_unavailable')
     this.nodes.requireSource(owner,agent);return agent
   }
   allowed(id:string,runnerId:string):boolean{
@@ -33,7 +33,7 @@ export class ComputerControlService {
     router.get('/api/app/computers',ctx=>{
       const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
       ctx.body={canManageLocalVm:actor.role==='admin',computers:agents.map(agent=>{
-        let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
+        let available=false,reason='在机器人设置中将执行环境设为隔离电脑'
         if(agent.execution==='computer')try{this.nodes.requireSource(actor.id,agent);const record=this.hub.computerRunner(actor.id,agent),features=this.hub.summary(record).features;if(features.includes('local-vm-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在应用设置的本地虚拟机页面完成准备');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
         return {agent,available,reason}
       })}

+ 211 - 0
src/server/grokCloud.ts

@@ -0,0 +1,211 @@
+import Router from '@koa/router'
+import {createHash,randomBytes,randomUUID,timingSafeEqual} from 'node:crypto'
+import {readFile} from 'node:fs/promises'
+import {homedir} from 'node:os'
+import {join} from 'node:path'
+import {z} from 'zod'
+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 {requireTeamToolBridge} from './workspaceToolLease.js'
+import type {SharedComputers} from './sharedComputers.js'
+import 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}
+interface Credential {accessToken:string;account:string;version:string}
+interface Manual {pending?:Promise<unknown>;releasing?:boolean;id:string;agentId:string;requestId:string;tokenHash:Buffer;token:string;expiresAt:number;generation:number;version:number;notes?:string;actions:Map<string,{fingerprint:string;value:Promise<unknown>}>}
+const hash=(v:string)=>createHash('sha256').update(v).digest('hex')
+const field={type:'string'}
+const schema=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
+export const GROK_COMPUTER_TOOLS=[
+  {id:'cloud_computer_shell',name:'cloud_computer_shell',description:'在共享的 Grok Bot 云端电脑 /workspace 中执行命令。不会在本机执行。',inputSchema:schema({command:field},['command'])},
+  {id:'cloud_computer_read',name:'cloud_computer_read',description:'读取 Grok Bot 云端电脑的 UTF-8 文件。',inputSchema:schema({path:field},['path'])},
+  {id:'cloud_computer_write',name:'cloud_computer_write',description:'写入 Grok Bot 云端电脑的 UTF-8 文件。',inputSchema:schema({path:field,content:field},['path','content'])},
+  {id:'cloud_computer_desktop',name:'cloud_computer_desktop',description:'查看 Grok Bot 云端电脑桌面,返回真实截图。',inputSchema:schema({},[])},
+  {id:'cloud_computer_action',name:'cloud_computer_action',description:'操作 Grok Bot 云端电脑。先查看桌面,再使用 click、text、key 或 scroll。',inputSchema:schema({kind:{enum:['click','text','key','scroll']},x:{type:'integer'},y:{type:'integer'},text:field,key:field,direction:{enum:['up','down','left','right']}},['kind'])},
+]
+export const GROK_COMPUTER_RULES='当前电脑为 Grok Bot 云端共享虚拟机,工作目录 /workspace。电脑操作、文件读写和命令必须使用 cloud_computer_* 工具。本机路径和本机终端不属于这台电脑。多个机器人共享工作文件和浏览器登录,避免覆盖其他机器人的工作;人工接管时等待交还。'
+
+/** Credentials and network tokens stay on the Web server. Every robot of an
+ * account addresses the same existing cloud computer; robots keep their own
+ * Hermes sessions and permissions. No cloud provisioning happens in GETs. */
+export class GrokCloud {
+  private descriptors=new Map<string,{value:Descriptor;expiresAt:number}>()
+  private connecting=new Map<string,Promise<Descriptor>>()
+  private queue=new Map<string,Promise<unknown>>()
+  private manual=new Map<string,Manual>()
+  private frames=new Map<string,ComputerFrame>()
+  private paused=new Set<string>()
+  private handoff=new Map<string,string>()
+  private generations=new Map<string,number>()
+  private activity=new Map<string,number>()
+  private executing=new Map<string,number>()
+  private stateCache=new Map<string,{at:number;value:{running:boolean}}>()
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly shared:SharedComputers,
+    readonly fetcher:typeof fetch=fetch,readonly broker='https://api2.cursor.sh'){}
+  configured(owner:string){return !!this.store.get(owner,'grok-cloud','connection')}
+  selected(owner:string,agent:WorkspaceAgent){return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&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}
+  private async response(response:Response){
+    const text=await response.text()
+    let value:any;try{value=JSON.parse(text)}catch{throw new HttpError(502,'Grok Bot 返回了无效响应','grok_response_invalid')}
+    if(!response.ok)throw new HttpError(response.status===401?401:502,response.status===401?'Grok Bot 登录已失效或客户端版本不再受支持,请重新连接。':`Grok Bot 请求失败(${response.status})`,'grok_request_failed')
+    return value
+  }
+  private async rpc(owner:string,method:string,body:unknown={}){
+    const credential=this.credential(owner)
+    return this.response(await this.fetcher(`${this.broker}/aiserver.v1.GrokBotService/${method}`,{method:'POST',redirect:'error',headers:{authorization:`Bearer ${credential.accessToken}`,'content-type':'application/json','connect-protocol-version':'1','x-cursor-client-type':'sand','x-cursor-client-version':credential.version,'x-sand-box-namespace':'prod','x-ghost-mode':'true'},body:JSON.stringify(body),signal:AbortSignal.timeout(30000)}))
+  }
+  async configure(owner:string,accessToken:string,version:string){
+    if(this.current(owner)||this.queue.has(owner)||(this.activity.get(owner)??0)>0)throw new HttpError(409,'请先结束云端电脑操作,再更新连接','computer_busy')
+    let account:string;try{const value=JSON.parse(Buffer.from(accessToken.split('.')[1]!,'base64url').toString());if(!value.sub||value.exp*1000<=Date.now())throw new Error();account=hash(value.sub)}catch{throw new HttpError(400,'请使用有效的 Grok Bot 登录凭据','grok_credential_invalid')}
+    const claim=this.store.get<{owner:string}>('_system','grok-account',account)
+    if(claim&&claim.owner!==owner)throw new HttpError(409,'该云端账号已连接其他用户','grok_account_in_use')
+    const old=this.store.get<{sealed:string}>(owner,'grok-cloud','connection')
+    this.store.put(owner,'grok-cloud','connection',{sealed:this.nodes.seal({accessToken,account,version})})
+    try{await this.rpc(owner,'GetSandBoxRunState')}
+    catch(error){if(old)this.store.put(owner,'grok-cloud','connection',old);else this.store.remove(owner,'grok-cloud','connection');throw error}
+    this.store.put('_system','grok-account',account,{owner});this.descriptors.delete(owner);this.stateCache.delete(owner)
+    return {configured:true,version}
+  }
+  async state(owner:string){
+    if(!this.configured(owner))return {configured:false,running:false}
+    const cached=this.stateCache.get(owner);if(cached&&Date.now()-cached.at<5000)return {configured:true,...cached.value}
+    const value=await this.rpc(owner,'GetSandBoxRunState'),state={running:value.state==='SAND_BOX_RUN_STATE_RUNNING'}
+    this.stateCache.set(owner,{at:Date.now(),value:state});return {configured:true,...state}
+  }
+  async connect(owner:string){
+    const cached=this.descriptors.get(owner);if(cached&&cached.expiresAt>Date.now())return cached.value
+    const pending=this.connecting.get(owner);if(pending)return pending
+    const work=(async()=>{
+      const value=await this.rpc(owner,'EnsureSandBox') as Descriptor
+      for(const key of ['gatewayUrl','execDaemonUrl'] as const){const url=new URL(value[key]);if(url.protocol!=='https:'||!url.hostname.endsWith('.cursorvm.com')||url.username||url.password)throw new HttpError(502,'云端电脑地址无效','grok_endpoint_invalid')}
+      if(!value.gatewayToken||!value.networkToken||!value.execDaemonAuthToken)throw new HttpError(502,'云端电脑连接信息不完整','grok_endpoint_invalid')
+      this.descriptors.set(owner,{value,expiresAt:Date.now()+5*60000});return value
+    })().finally(()=>this.connecting.delete(owner))
+    this.connecting.set(owner,work);return work
+  }
+  private descriptor(owner:string){const value=this.descriptors.get(owner);if(!value||value.expiresAt<=Date.now())throw new HttpError(409,'请打开云端电脑以建立连接','grok_connection_required');return value.value}
+  private headers(d:Descriptor){return {authorization:`Bearer ${d.execDaemonAuthToken}`,'x-anyrun-network-token':d.networkToken,'connect-protocol-version':'1'}}
+  async execute(owner:string,command:string,args:string[],signal?:AbortSignal){
+    const d=this.descriptor(owner),json=Buffer.from(JSON.stringify({command,args,cwd:'/workspace'})),body=Buffer.alloc(5+json.length);body.writeUInt32BE(json.length,1);json.copy(body,5)
+    const response=await this.fetcher(`${d.execDaemonUrl.replace(/\/$/,'')}/agent.v1.ControlService/Exec`,{method:'POST',redirect:'error',headers:{...this.headers(d),'content-type':'application/connect+json'},body,signal:signal?AbortSignal.any([signal,AbortSignal.timeout(60000)]):AbortSignal.timeout(60000)})
+    if(!response.ok)throw new HttpError(502,`云端命令未被接受(${response.status})`,'grok_exec_failed')
+    let buffer=Buffer.alloc(0),stdout='',stderr='',exitCode:number|undefined,total=0
+    for await(const chunk of response.body! as any){total+=chunk.length;if(total>8*1024*1024)throw new HttpError(502,'云端命令输出超过限制','grok_output_limit');buffer=Buffer.concat([buffer,Buffer.from(chunk)])
+      while(buffer.length>=5){const size=buffer.readUInt32BE(1);if(size>8*1024*1024)throw new HttpError(502,'云端响应超过限制','grok_output_limit');if(buffer.length<5+size)break
+        const flags=buffer[0],event=JSON.parse(buffer.subarray(5,5+size).toString());buffer=buffer.subarray(5+size)
+        if(flags===2&&event.error)throw new HttpError(502,'云端命令未正常结束','grok_exec_failed')
+        if(event.stdoutEvent)stdout+=event.stdoutEvent.data??'';if(event.stderrEvent)stderr+=event.stderrEvent.data??'';if(event.exitEvent)exitCode=event.exitEvent.exitCode??0
+      }
+    }
+    if(buffer.length||exitCode===undefined)throw new HttpError(502,'云端命令的完成状态不确定,未自动重试','grok_exec_uncertain')
+    return {stdout,stderr,exitCode}
+  }
+  async capture(owner:string):Promise<ComputerFrame>{
+    const cached=this.frames.get(owner),generation=this.generations.get(owner)??0
+    if(cached&&Date.now()-cached.capturedAt<900&&cached.generation===generation)return cached
+    const script="import os,io,base64,json; os.environ['DISPLAY']=':1'; from PIL import ImageGrab; im=ImageGrab.grab(xdisplay=':1'); b=io.BytesIO(); im.save(b,format='PNG'); print(json.dumps({'data':base64.b64encode(b.getvalue()).decode(),'width':im.width,'height':im.height}))"
+    const result=await this.execute(owner,'python3',['-c',script])
+    if(result.exitCode)throw new HttpError(502,'云端桌面截图暂不可用,请重试','grok_frame_failed')
+    const frame={...JSON.parse(result.stdout),id:randomUUID(),capturedAt:Date.now(),generation} as ComputerFrame
+    if(!frame.data||!Number.isInteger(frame.width)||!Number.isInteger(frame.height))throw new HttpError(502,'云端画面无效','grok_frame_failed')
+    this.frames.set(owner,frame);return frame
+  }
+  private current(owner:string){const manual=this.manual.get(owner);if(manual&&(manual.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(owner)!==manual.version)){this.manual.delete(owner);this.paused.add(owner);this.store.remove('_system','computer-control',manual.id);this.generations.set(owner,manual.generation+1);return undefined}return manual}
+  async status(owner:string):Promise<ComputerControlStatus>{const manual=this.current(owner);if(manual)return {backend:'grok',mode:'human',controlId:manual.id,generation:manual.generation,canResume:(this.activity.get(owner)??0)>0};if(this.paused.has(owner))return {backend:'grok',mode:'error',error:'接管连接已断开,请重新接管并交还后继续',generation:this.generations.get(owner)??0};const state=await this.state(owner);return {backend:'grok',mode:state.running?(this.activity.get(owner)?'agent':'idle'):'off',generation:this.generations.get(owner)??0}}
+  private serial<T>(owner:string,work:()=>Promise<T>):Promise<T>{const previous=this.queue.get(owner)??Promise.resolve();const next=previous.catch(()=>{}).then(work);this.queue.set(owner,next);void next.finally(()=>{if(this.queue.get(owner)===next)this.queue.delete(owner)}).catch(()=>{});return next}
+  private async available(owner:string,agentId:string,signal?:AbortSignal){
+    while(this.current(owner)||this.paused.has(owner)){signal?.throwIfAborted();this.authorize(owner,agentId);await new Promise(resolve=>setTimeout(resolve,200))}
+    signal?.throwIfAborted();this.authorize(owner,agentId)
+  }
+  async requireAvailable(owner:string,agent:WorkspaceAgent,target:GatewayTarget){this.credential(owner);await requireTeamToolBridge(target,agent.profile)}
+  async call(owner:string,agentId:string,id:string,input:unknown,signal?:AbortSignal){
+    this.activity.set(owner,(this.activity.get(owner)??0)+1)
+    try{const result=await this.serial(owner,async()=>{
+      await this.available(owner,agentId,signal);await this.connect(owner);await this.available(owner,agentId,signal);this.authorize(owner,agentId)
+      this.executing.set(owner,(this.executing.get(owner)??0)+1)
+      try{
+      if(id==='cloud_computer_desktop'){const frame=await this.capture(owner);return {content:[{type:'image',mimeType:'image/png',data:frame.data},{type:'text',text:JSON.stringify({width:frame.width,height:frame.height})}]}}
+      if(id==='cloud_computer_shell'){const args=parse(z.object({command:z.string().min(1).max(16000)}).strict(),input);return await this.execute(owner,'/bin/sh',['-c',args.command],signal)}
+      if(id==='cloud_computer_read'){const args=parse(z.object({path:z.string().min(1).max(4096)}).strict(),input);return await this.execute(owner,'python3',['-c',"import pathlib,sys; p=pathlib.Path(sys.argv[1]); print(p.read_text()[:64000])",args.path],signal)}
+      if(id==='cloud_computer_write'){const args=parse(z.object({path:z.string().min(1).max(4096),content:z.string().max(64000)}).strict(),input);return await this.execute(owner,'python3',['-c',"import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_text(sys.argv[2]); print('written')",args.path,args.content],signal)}
+      if(id==='cloud_computer_action')return await this.input(owner,input,signal)
+      throw new HttpError(404,'云端工具不存在','tool_not_found')
+      }finally{this.executing.set(owner,Math.max(0,(this.executing.get(owner)??1)-1))}
+    });const note=this.handoff.get(owner);if(note){this.handoff.delete(owner);return result&&typeof result==='object'&&Array.isArray((result as any).content)?{...result,content:[...(result as any).content,{type:'text',text:'人工交还说明:'+note}]}:{...result,handoffNote:note}}return result}finally{this.activity.set(owner,Math.max(0,(this.activity.get(owner)??1)-1))}
+  }
+  private async input(owner:string,input:unknown,signal?:AbortSignal){
+    const action=parse(z.discriminatedUnion('kind',[
+      z.object({kind:z.literal('click'),x:z.number().int().min(0).max(16384),y:z.number().int().min(0).max(16384),button:z.enum(['left','right','middle']).optional(),count:z.number().int().min(1).max(2).optional()}).strict(),
+      z.object({kind:z.literal('drag'),fromX:z.number().int().min(0).max(16384),fromY:z.number().int().min(0).max(16384),toX:z.number().int().min(0).max(16384),toY:z.number().int().min(0).max(16384)}).strict(),
+      z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
+      z.object({kind:z.literal('key'),key:z.string().regex(/^[A-Za-z0-9_+ -]{1,40}$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).optional()}).strict(),
+      z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).optional()}).strict(),
+    ]),input)
+    const argv=action.kind==='text'?['type','--clearmodifiers','--',action.text]:action.kind==='key'?['key','--clearmodifiers',[...(action.modifiers??[]),action.key].join('+')]:action.kind==='scroll'?['click','--repeat',String(action.amount??3),String({up:4,down:5,left:6,right:7}[action.direction])]:action.kind==='drag'?['mousemove',String(action.fromX),String(action.fromY),'mousedown','1','mousemove',String(action.toX),String(action.toY),'mouseup','1']:['mousemove',String(action.x),String(action.y),'click','--repeat',String(action.count??1),String({left:1,middle:2,right:3}[action.button??'left'])]
+    const result=await this.execute(owner,'/usr/bin/env',['DISPLAY=:1','xdotool',...argv],signal)
+    if(result.exitCode!==0)throw new HttpError(502,'云端桌面未接受输入','grok_input_failed')
+    this.frames.delete(owner);return {ok:true}
+  }
+  private grant(owner:string,agentId:string,body:unknown){const value=parse(z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough(),body),manual=this.current(owner);if(!manual||manual.id!==value.controlId||manual.agentId!==agentId||!timingSafeEqual(manual.tokenHash,Buffer.from(hash(value.token),'hex')))throw new HttpError(410,'云端控制权已失效','computer_control_expired');this.authorize(owner,agentId);return manual}
+  router(){const router=new Router()
+    router.get('/api/app/grok-cloud',async ctx=>{const owner=this.auth.require(ctx).id;ctx.body={...await this.state(owner),version:this.configured(owner)?this.credential(owner).version:'0.47.0'}})
+    router.post('/api/app/grok-cloud/connect',async ctx=>{const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({accessToken:z.string().min(32).max(16000).optional(),version:z.string().regex(/^\d+\.\d+\.\d+$/).default('0.47.0'),importLocal:z.boolean().optional()}).strict(),(ctx.request as any).body);let token=body.accessToken
+      if(body.importLocal){try{token=JSON.parse(await readFile(join(homedir(),'.grokbot','local-docker-credential','inference.json'),'utf8')).accessToken}catch{throw new HttpError(409,'没有找到本机 Grok Bot 登录,请粘贴登录凭据','grok_login_missing')}}
+      if(!token)throw new HttpError(400,'请提供 Grok Bot 登录凭据','grok_login_missing');ctx.body=await this.configure(owner,token,body.version)
+    })
+    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','off'])}).strict(),(ctx.request as any).body)
+      this.shared.assertLocalVmIdle(owner,[agent.id]);if(this.current(owner))throw new HttpError(409,'请先交还云端电脑','computer_busy')
+      if(body.computer==='cloud')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});const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id);this.store.prepareAgent?.(owner,current);this.store.put(owner,'agent',agent.id,current)})
+      if(body.computer==='cloud')await this.connect(owner)
+      ctx.body={agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
+    })
+    router.get('/api/app/agents/:id/cloud-computer',async ctx=>{const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id);ctx.body={...await this.state(owner),selected:this.selected(owner,agent),connected:!!this.descriptors.get(owner)&&this.descriptors.get(owner)!.expiresAt>Date.now(),shared:true,...await this.status(owner)}})
+    router.post('/api/app/agents/:id/cloud-computer/open',async ctx=>{const owner=this.auth.require(ctx).id;this.authorize(owner,ctx.params.id);await this.connect(owner);ctx.body={ok:true}})
+    // These routes precede the local computer router and preserve its wire
+    // protocol so the desktop and iOS takeover viewers share one UI contract.
+    router.use('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.state.grokOwner=owner;return next()})
+    router.get('/api/app/agents/:id/computer',async(ctx,next)=>{const owner=ctx.state.grokOwner;if(!owner)return next();ctx.body=await this.status(owner)})
+    router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id);ctx.set('Cache-Control','no-store');ctx.body=await this.capture(owner)})
+    router.post('/api/app/agents/:id/computer/:action',async(ctx,next)=>{
+      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);if(!this.selected(owner,agent))return next();this.authorize(owner,agent.id)
+      const body=(ctx.request as any).body,action=ctx.params.action
+      if(action==='take'){
+        const value=parse(z.object({requestId:z.string().uuid()}).strict(),body),old=this.current(owner)
+        if(old){if(old.agentId!==agent.id||old.requestId!==value.requestId)throw new HttpError(409,'其他页面正在控制同一台云端电脑','computer_busy');ctx.body={...await this.status(owner),token:old.token};return}
+        await this.connect(owner)
+        const d=this.descriptor(owner),health=await this.response(await this.fetcher(d.gatewayUrl+'/health',{headers:{authorization:`Bearer ${d.gatewayToken}`,'x-anyrun-network-token':d.networkToken},signal:AbortSignal.timeout(10000)}))
+        if(health.isBusy)throw new HttpError(409,'Grok Bot 正在操作这台电脑,请先在 Grok Bot 中暂停后接管','computer_busy')
+        if(this.current(owner))throw new HttpError(409,'其他页面已取得这台电脑的控制权','computer_busy')
+        const token=randomBytes(32).toString('base64url'),generation=(this.generations.get(owner)??0)+1
+        const manual:Manual={id:randomUUID(),agentId:agent.id,requestId:value.requestId,token,tokenHash:Buffer.from(hash(token),'hex'),generation,expiresAt:Date.now()+90000,version:this.auth.pushAuthorizationVersion(owner)??0,actions:new Map()}
+        this.manual.set(owner,manual);this.generations.set(owner,generation)
+        this.store.put('_system','computer-control',manual.id,{owner,agentId:agent.id,environmentId:'grok:'+this.credential(owner).account,expiresAt:manual.expiresAt})
+        while(this.executing.get(owner)){if(!this.current(owner))throw new HttpError(410,'接管等待超时,请重试','computer_control_expired');await new Promise(resolve=>setTimeout(resolve,100))}
+        manual.expiresAt=Date.now()+30000
+        ctx.body={...await this.status(owner),token};return
+      }
+      const manual=this.grant(owner,agent.id,body)
+      if(action==='renew'){manual.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',manual.id,{owner,agentId:agent.id,environmentId:'grok:'+this.credential(owner).account,expiresAt:manual.expiresAt});ctx.body={ok:true};return}
+      if(action==='giveback'){if(typeof body.notes==='string'&&body.notes.trim())this.handoff.set(owner,body.notes.trim().slice(0,4000));manual.releasing=true;await manual.pending?.catch(()=>{});this.manual.delete(owner);this.paused.delete(owner);this.store.remove('_system','computer-control',manual.id);this.generations.set(owner,manual.generation+1);this.frames.delete(owner);ctx.body={ok:true};return}
+      if(action==='input'){
+        const value=parse(z.object({controlId:z.string(),token:z.string(),requestId:z.string().uuid(),generation:z.number().int(),frameId:z.string().uuid(),action:z.unknown()}).strict(),body),frame=this.frames.get(owner)
+        const fingerprint=JSON.stringify([value.generation,value.frameId,value.action]),previous=manual.actions.get(value.requestId)
+        if(previous){if(previous.fingerprint!==fingerprint)throw new HttpError(409,'输入请求编号冲突','idempotency_conflict');ctx.body=await previous.value;return}
+        if(!frame||frame.id!==value.frameId||frame.generation!==value.generation||manual.generation!==value.generation||Date.now()-frame.capturedAt>10000)throw new HttpError(409,'桌面画面已过期,请刷新后操作','computer_frame_stale')
+        if(manual.actions.size>=512)throw new HttpError(429,'请交还后重新接管电脑','computer_input_limit')
+        if(manual.releasing)throw new HttpError(409,'正在交还控制权','computer_control_expired')
+        const result=(manual.pending??Promise.resolve()).catch(()=>{}).then(()=>{this.grant(owner,agent.id,body);return this.input(owner,value.action)});manual.pending=result;manual.actions.set(value.requestId,{fingerprint,value:result});ctx.body=await result;return
+      }
+      throw new HttpError(404,'电脑操作不存在','not_found')
+    })
+    return router
+  }
+}

+ 1 - 1
src/server/localAuth.ts

@@ -237,7 +237,7 @@ export class LocalAuthStore {
 
   validateAssignedProfiles(value: unknown): string[] {
     if (!Array.isArray(value) || value.length > 256 || value.some(p => typeof p !== 'string' || !p.trim() || p.length > 256 || /[\/\\\u0000-\u001f]/.test(p))) {
-      throw new HttpError(400, '分配的基础 Agent 无效', 'invalid_assigned_profiles')
+      throw new HttpError(400, '分配的基础机器人无效', 'invalid_assigned_profiles')
     }
     return [...new Set(value.map(p => p.trim()))]
   }

+ 4 - 3
src/server/localVm.ts

@@ -30,6 +30,7 @@ export class LocalVmService {
     readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
     store.prepareAgent=(owner,agent)=>{
       const record=this.record()
+      if(!this.fixed&&agent.computer==='auto'&&record){const state=this.hub.summary(record);if(state.online&&state.features.includes('local-vm-v1')&&state.features.includes('image-ready-v1'))agent.execution='computer'}
       if(this.fixed){const parent=agent.createdByAgentId?store.get<WorkspaceAgent>(owner,'agent',agent.createdByAgentId):undefined,desktop=this.config.composeDesktops?.find(d=>d.id===parent?.computerEnvironmentId);if(record&&desktop&&agent.execution==='computer'){if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required');this.shared.bindCompose(owner,agent,desktop,record.id,false)}return}
       if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)
     }
@@ -170,7 +171,7 @@ export class LocalVmService {
     router.put('/api/app/agents/:id/local-vm',ctx=>{
       const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean(),desktopId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
       const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
-      if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该 Agent 所属的电脑上设置虚拟机','local_vm_remote')
+      if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该机器人所属的电脑上设置虚拟机','local_vm_remote')
       this.shared.assertLocalVmIdle(owner,[agent.id])
       if(this.fixed&&body.enabled){
         if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required')
@@ -181,7 +182,7 @@ export class LocalVmService {
       }
       ctx.body=this.store.atomic(()=>{
         if(!body.enabled)this.shared.detachLocalVm(owner,agent)
-        this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile'})
+        this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile',computer:body.enabled?'vm':'off'})
         if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)this.shared.setLocalVmMode(owner,'shared',record.id)}
         return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
       })
@@ -191,7 +192,7 @@ export class LocalVmService {
       const owner=this.auth.require(ctx).id,action=parse(z.enum(['create','start','stop','recreate','remove']),ctx.params.action)
       const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
       if(agent.temporaryGoalId)throw new HttpError(409,'临时助手的虚拟机由当前任务管理','helper_task_bound')
-      if(agent.execution!=='computer'||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此 Agent 未使用本地虚拟机','local_vm_disabled')
+      if(agent.execution!=='computer'||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此机器人未使用本地虚拟机','local_vm_disabled')
       this.shared.assertLocalVmIdle(owner,agent.computerEnvironmentId?this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.computerEnvironmentId===agent.computerEnvironmentId).map(a=>a.id):[agent.id])
       ctx.body=await this.hub.computer(owner,agent,'lifecycle',{action},()=>this.nodes.requireSource(owner,agent))
     })

+ 1 - 1
src/server/pairing.ts

@@ -329,7 +329,7 @@ export class NodePairingStore {
   workspaceOwner(deviceID: string, token: string, scope: NodeScope): string {
     this.authorize(deviceID, token, scope)
     const owner = this.#state.devices.find(device => device.id === deviceID)?.workspaceOwner
-    if (!owner) throw new HttpError(403, '请升级远端并重新扫码授权,以读取远端 Bot 模式 Agent', 'workspace_agent_pairing_required')
+    if (!owner) throw new HttpError(403, '请升级远端并重新扫码授权,以读取远端 Bot 模式机器人', 'workspace_agent_pairing_required')
     return owner
   }
 

+ 1 - 1
src/server/profileIdentities.ts

@@ -66,7 +66,7 @@ export class UpstreamProfileIdentityService {
         if (finished) return
         finished = true
         cleanup()
-        reject(new HttpError(502, `无法读取 Agent 身份:${error.message}`, 'profile_identity_failed'))
+        reject(new HttpError(502, `无法读取机器人身份:${error.message}`, 'profile_identity_failed'))
       }
       const send = (id: string, method: string, params: JsonObject) => {
         socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))

+ 5 - 5
src/server/pushEvents.ts

@@ -593,7 +593,7 @@ export class ChatPushRelayObserver {
         profile: job.profile,
         sessionID: job.storedSessionID,
         requestID: requestIDValue,
-        title: clarification ? 'Agent 需要补充信息' : 'Agent 请求审批',
+        title: clarification ? '机器人需要补充信息' : '机器人请求审批',
         body: summary(payload.question ?? payload.message ?? payload.prompt, clarification ? '请打开夭夭继续处理' : '请打开夭夭审批'),
         collapseID: collapseID('chat-interaction', `${job.storedSessionID ?? runtimeSessionID}:${requestIDValue}`),
         data: notificationData(eventID, kind, job.localUserID, {
@@ -697,8 +697,8 @@ function terminalGroupCandidate(
       error: error || undefined,
       occurredAt: eventTimestamp(payload.updatedAt ?? payload.updated_at ?? payload.createdAt ?? payload.created_at),
       title: status === 'completed'
-        ? `${string(payload.senderName ?? payload.sender_name) || 'Agent'} 已回复`
-        : `${string(payload.senderName ?? payload.sender_name) || 'Agent'} 执行失败`,
+        ? `${string(payload.senderName ?? payload.sender_name) || '机器人'} 已回复`
+        : `${string(payload.senderName ?? payload.sender_name) || '机器人'} 执行失败`,
       body: summary(error || content, status === 'completed' ? '打开夭夭查看团队消息' : '打开夭夭查看失败详情'),
       collapseID: collapseID('group-message', messageID),
     }
@@ -723,7 +723,7 @@ function terminalGroupCandidate(
     interactionKind,
     content: content || undefined,
     occurredAt: eventTimestamp(payload.updatedAt ?? payload.updated_at ?? payload.createdAt ?? payload.created_at),
-    title: interactionKind === 'clarification' ? '团队 Agent 需要补充信息' : '团队 Agent 请求审批',
+    title: interactionKind === 'clarification' ? '团队机器人需要补充信息' : '团队机器人请求审批',
     body: summary(content, '请打开夭夭继续处理'),
     collapseID: collapseID('group-interaction', interactionID),
   }
@@ -990,7 +990,7 @@ function chatInteractionCandidate(
     profile: job.profile,
     sessionID: job.storedSessionID,
     requestID: requestIDValue,
-    title: clarification ? 'Agent 需要补充信息' : 'Agent 请求审批',
+    title: clarification ? '机器人需要补充信息' : '机器人请求审批',
     body: summary(payload.question ?? payload.message ?? payload.prompt, clarification ? '请打开夭夭继续处理' : '请打开夭夭审批'),
     collapseID: collapseID('chat-interaction', `${job.storedSessionID ?? job.runtimeSessionID}:${requestIDValue}`),
     data: notificationData(eventID, kind, job.localUserID, {

+ 1 - 1
src/server/realtimeApi.ts

@@ -175,7 +175,7 @@ export class RealtimeAPI {
       this.checkMutation(ctx, Boolean(device))
       if (ctx.method === 'POST' && path === '/channels') {
         const body = await this.body(ctx)
-        if (ctx.state.workspaceAgentExport && body.channel !== 'chat') throw new HttpError(403,'Agent 仅支持聊天通道','invalid_channel')
+        if (ctx.state.workspaceAgentExport && body.channel !== 'chat') throw new HttpError(403,'机器人仅支持聊天通道','invalid_channel')
         if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
         const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)

+ 1 - 1
src/server/realtimeBroker.ts

@@ -234,7 +234,7 @@ export class RealtimeBroker {
           if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
           route.workingDirectory = await applyWorkingDirectory(async (method, params) => {
             const response = await this.rpc(u, method, params)
-            if (response.error) throw new HttpError(502, response.error.message || '无法切换到 Agent 工作目录', 'working_directory_rejected')
+            if (response.error) throw new HttpError(502, response.error.message || '无法切换到机器人工作目录', 'working_directory_rejected')
             return response.result
           }, route.runtime, cwd, route.workingDirectory, route.cwd)
           if (route.workingDirectory) route.cwd = route.workingDirectory.resolved

+ 1 - 1
src/server/runnerHub.ts

@@ -118,7 +118,7 @@ export class RunnerHub {
       if(computer&&this.composeDesktops.desktops.length){
         if(!this.online.get(record.id)?.features.includes('compose-desktops-v1'))throw new HttpError(409,'当前部署需要 Compose 桌面执行节点','compose_runner_required')
         const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',computer.environmentId)
-        if(!this.composeDesktops.desktops.some(d=>d.id===computer.environmentId)||claim?.owner!==owner||claim.runnerId!==record.id)throw new HttpError(409,'请先为 Agent 选择已有 Compose 共享桌面','compose_desktop_required')
+        if(!this.composeDesktops.desktops.some(d=>d.id===computer.environmentId)||claim?.owner!==owner||claim.runnerId!==record.id)throw new HttpError(409,'请先为机器人选择已有 Compose 共享桌面','compose_desktop_required')
       }
       if(computer&&!this.online.get(record.id)?.features.includes('computer-worker-v1'))throw new HttpError(409,'执行节点未提供隔离 Worker 能力,请检查配置或更新 Runner','computer_unavailable')
       if(computer&&computer.environmentId!==computer.agentId&&!this.online.get(record.id)?.features.includes('shared-computer-v1'))throw new HttpError(409,'执行节点不支持共享电脑,请更新 Runner','shared_computer_unavailable')

+ 3 - 3
src/server/sessionWorkingDirectory.ts

@@ -10,17 +10,17 @@ export async function configuredWorkingDirectory(request: Request, profile: stri
     search: new URLSearchParams({ profile }), cache: 'reload',
   })
   if (response.status !== 200) {
-    throw new HttpError(502, '无法读取 Agent 的 terminal.cwd,未开始执行', 'working_directory_unavailable')
+    throw new HttpError(502, '无法读取机器人的 terminal.cwd,未开始执行', 'working_directory_unavailable')
   }
   let config: any
   try { config = JSON.parse(response.body.toString()) } catch {
-    throw new HttpError(502, 'Agent 工作目录配置响应无效', 'invalid_working_directory')
+    throw new HttpError(502, '机器人工作目录配置响应无效', 'invalid_working_directory')
   }
   const raw = config?.terminal?.cwd
   // Older Hermes installations may not expose this setting.
   if (raw === undefined || raw === null || raw === '') return undefined
   if (typeof raw !== 'string' || !raw.trim() || raw.length > 4096 || /[\u0000-\u001f\u007f]/.test(raw)) {
-    throw new HttpError(502, 'Agent 的 terminal.cwd 配置无效', 'invalid_working_directory')
+    throw new HttpError(502, '机器人的 terminal.cwd 配置无效', 'invalid_working_directory')
   }
   return raw.trim()
 }

+ 12 - 10
src/server/sharedComputers.ts

@@ -12,7 +12,7 @@ export interface SharedComputer {managedCompose?:boolean;managedLocalVm?:boolean
 export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
   if(!agent.computerEnvironmentId)return
   const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
-  if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||(!shared.managedCompose&&shared.profile!==agent.profile))throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
+  if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||(!shared.managedCompose&&!shared.managedLocalVm&&shared.profile!=='*'&&shared.profile!==agent.profile))throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
 }
 export class SharedComputers {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
@@ -43,8 +43,8 @@ export class SharedComputers {
   attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){
     if(agent.nodeId!=='local'||agent.execution!=='computer'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
     this.nodes.requireSource(owner,agent)
-    const group=this.store.list<SharedComputer>(owner,'shared-computer').find(g=>!g.archived&&g.managedLocalVm&&g.runnerId===runnerId&&g.profile===agent.profile)
-      ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:agent.profile,runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
+    const group=this.store.list<SharedComputer>(owner,'shared-computer').find(g=>!g.archived&&g.managedLocalVm&&g.runnerId===runnerId)
+      ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
     if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
     this.store.put(owner,'shared-computer',group.id,group)
     agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name
@@ -67,11 +67,13 @@ export class SharedComputers {
       for(const agent of agents){
         if(agent.computerEnvironmentId&&groups.some(g=>g.id===agent.computerEnvironmentId)){delete agent.computerEnvironmentId;delete agent.computerEnvironmentName}
       }
-      if(mode==='shared')for(const profile of new Set(agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId).map(a=>a.profile))){
-        const members=agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId&&a.profile===profile)
-        const group=groups.find(g=>g.profile===profile&&g.runnerId===runnerId)??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile,runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
-        group.archived=false;group.managedLocalVm=true;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
-        for(const agent of members){agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name}
+      if(mode==='shared'){
+        const members=agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId)
+        if(members.length){
+          const group=groups.find(g=>g.managedLocalVm)??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
+          group.profile='*';group.archived=false;group.managedLocalVm=true;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
+          for(const agent of members){agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name}
+        }
       }
       for(const agent of agents){
         const before=previous.get(agent.id)!
@@ -88,11 +90,11 @@ export class SharedComputers {
       const agents=body.memberIds.map(id=>this.store.require<WorkspaceAgent>(owner,'agent',id)),first=agents[0]!
       for(const agent of agents){
         this.nodes.requireSource(owner,agent)
-        if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId||agent.execution!=='computer'||agent.computerEnvironmentId||agent.nodeId!==first.nodeId||agent.profile!==first.profile)throw new HttpError(400,'请选择同一来源和 Profile 的持久隔离成员,且成员尚未加入其他共享电脑','shared_computer_members')
+        if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId||agent.execution!=='computer'||agent.computerEnvironmentId||agent.nodeId!==first.nodeId)throw new HttpError(400,'请选择同一来源的持久隔离成员,且成员尚未加入其他共享电脑','shared_computer_members')
       }
       this.idle(owner,body.memberIds)
       const runnerId=this.hub.sharedComputerRunner(owner,first).id
-      const shared:SharedComputer={id:randomUUID(),name:body.name,memberIds:body.memberIds,nodeId:first.nodeId,profile:first.profile,runnerId,archived:false,createdAt:Date.now()}
+      const shared:SharedComputer={id:randomUUID(),name:body.name,memberIds:body.memberIds,nodeId:first.nodeId,profile:'*',runnerId,archived:false,createdAt:Date.now()}
       this.store.put(owner,'shared-computer',shared.id,shared)
       for(const agent of agents){agent.computerEnvironmentId=shared.id;agent.computerEnvironmentName=shared.name;agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
       return shared

+ 1 - 1
src/server/taskCoordinator.ts

@@ -86,7 +86,7 @@ export class WorkspaceTaskCoordinator {
     const goal = this.store.require<AgentGoal>(owner, 'goal', goalId)
     const team = this.store.require<Conversation>(owner, 'conversation', goal.conversationId)
     if (!this.authorized(owner, goal) || goal.coordinatorId !== actorId || team.administratorId !== actorId)
-      throw new HttpError(403, '当前 Agent 无权管理这个任务', 'goal_forbidden')
+      throw new HttpError(403, '当前机器人无权管理这个任务', 'goal_forbidden')
     return goal
   }
   private authorized(owner: string, goal: AgentGoal): boolean {

+ 17 - 10
src/server/workspaceGateway.ts

@@ -45,7 +45,7 @@ export class WorkspaceNodes {
   sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
   requireSource(owner: string, agent: { id?:string;nodeId: string; profile: string;computerEnvironmentId?:string }): void {
     requireSharedComputer(this.store,owner,agent)
-    if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础 Agent 未分配给当前账号', 'agent_source_forbidden')
+    if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础机器人未分配给当前账号', 'agent_source_forbidden')
   }
   private readonly key: Buffer
   private targets = new Map<string, GatewayTarget>()
@@ -109,7 +109,7 @@ export class WorkspaceNodes {
   }
   targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;helperRunnerId?:string;computerEnvironmentId?:string }): GatewayTarget {
     if(agent.execution==='computer'){
-      if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的 Agent','computer_agent_required')
+      if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的机器人','computer_agent_required')
       const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.computerEnvironmentId??agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
       if(agent.computerEnvironmentId&&this.store.require<import('./sharedComputers.js').SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId).runnerId!==target?.runner?.id)throw new HttpError(409,'共享电脑的原执行节点已变化,请先恢复原节点','shared_runner_changed')
       if(agent.helperRunnerId&&target?.runner?.id!==agent.helperRunnerId)throw new HttpError(409,'临时助手的原执行节点已变化,请创建新助手','helper_runner_changed')
@@ -118,7 +118,7 @@ export class WorkspaceNodes {
     }
     const base = this.target(owner, agent.nodeId)
     if (!agent.remoteAgentId) return base
-    if (!base.pairedToken) throw new HttpError(409, '引用远端 Agent 需要扫码子节点', 'paired_node_required')
+    if (!base.pairedToken) throw new HttpError(409, '引用远端机器人需要扫码子节点', 'paired_node_required')
     const key = `${owner}:${agent.nodeId}:agent:${agent.remoteAgentId}`
     let target = this.targets.get(key)
     if (!target) {
@@ -126,7 +126,7 @@ export class WorkspaceNodes {
       const client = new UpstreamClient(url, base.client.fetchImpl)
       target = {url,client,pairedToken:token,session:{
         request:(path, options={})=>client.request(path,new CookieJar(),{...options,headers:{...options.headers,Authorization:`Bearer ${token}`}}),
-        webSocketCredential:async()=>{throw new Error('Remote Agent uses HTTP/SSE')},
+        webSocketCredential:async()=>{throw new Error('Remote 机器人 uses HTTP/SSE')},
       }}
       this.targets.set(key,target)
     }
@@ -141,10 +141,10 @@ export class WorkspaceNodes {
       const target=this.target(owner,nodeId)
       if (!target.pairedToken) throw new HttpError(409,'请先扫码添加远程子节点','paired_node_required')
       const response=await target.session.request('/api/workspace-agents')
-      if (response.status!==200) throw new HttpError(409,'远端需升级并重新扫码授权,才能添加 Bot 模式 Agent','remote_agents_unavailable')
+      if (response.status!==200) throw new HttpError(409,'远端需升级并重新扫码授权,才能添加 Bot 模式机器人','remote_agents_unavailable')
       const payload=JSON.parse(response.body.toString())
       const agents:Array<WorkspaceAgent>=Array.isArray(payload.agents) ? payload.agents.filter((a:WorkspaceAgent)=>!a.archived).map((a:WorkspaceAgent)=>{
-        if(typeof a.id!=='string'||!/^[0-9a-f-]{36}$/.test(a.id)) throw new HttpError(502,'远端 Agent 标识无效','invalid_remote_agent')
+        if(typeof a.id!=='string'||!/^[0-9a-f-]{36}$/.test(a.id)) throw new HttpError(502,'远端机器人标识无效','invalid_remote_agent')
         const fields=parse(agentInput,{name:a.name,avatar:a.avatar,instructions:a.instructions,nodeId:a.nodeId,profile:a.profile})
         return {...a,...fields}
       }) : []
@@ -165,7 +165,7 @@ export class WorkspaceNodes {
   }
   async importRemoteAgent(owner: string, nodeId: string, remoteAgentId: string): Promise<WorkspaceAgent> {
     const remote = (await this.remoteAgents(owner,nodeId,true)).find(a=>a.id===remoteAgentId)
-    if (!remote) throw new HttpError(404,'远端 Agent 不存在或已归档','remote_agent_not_found')
+    if (!remote) throw new HttpError(404,'远端机器人不存在或已归档','remote_agent_not_found')
     const existing = this.store.list<WorkspaceAgent>(owner,'agent').find(a=>a.nodeId===nodeId && a.remoteAgentId===remoteAgentId)
     if (existing) {
       const current=existing.archived ? this.store.updateAgent(owner,existing.id,{archived:false}) : existing
@@ -186,7 +186,7 @@ export class WorkspaceNodes {
   private async readRemoteAgent(owner: string, agent: WorkspaceAgent): Promise<WorkspaceAgent> {
     if (!agent.remoteAgentId) return agent
     const remote = (await this.remoteAgents(owner,agent.nodeId)).find(a=>a.id===agent.remoteAgentId)
-    if (!remote) throw new HttpError(404,'引用的远端 Agent 不存在或已归档','remote_agent_unavailable')
+    if (!remote) throw new HttpError(404,'引用的远端机器人不存在或已归档','remote_agent_unavailable')
     agent=this.store.require<WorkspaceAgent>(owner,'agent',agent.id)
     const latest = {...agent,name:this.uniqueRemoteName(owner,remote.name,agent.nodeId,agent.id),avatar:remote.avatar,instructions:remote.instructions,profile:remote.profile}
     if (latest.name!==agent.name || latest.avatar!==agent.avatar || latest.instructions!==agent.instructions || latest.profile!==agent.profile) {
@@ -224,7 +224,7 @@ export class WorkspaceNodes {
       || (replaced && claim.deviceId !== replaced.deviceId)
       || typeof claim.token !== 'string' || !claim.token || !/^[0-9a-f-]{36}$/.test(claim.deviceId ?? '')
       || !Array.isArray(claim.scopes) || !['agents.read', 'sessions.execute', 'history.read'].every(scope => claim.scopes.includes(scope)))
-      throw new HttpError(409, '子节点授权或身份不匹配,请重新扫码并授权 Agent 与会话访问', 'invalid_node_claim')
+      throw new HttpError(409, '子节点授权或身份不匹配,请重新扫码并授权机器人与会话访问', 'invalid_node_claim')
     const expected = new URL(`node/${claim.deviceId}`, code.url)
     if (claim.serverUrl !== expected.href) throw new HttpError(409, '子节点配对地址与二维码不一致', 'node_identity_mismatch')
     const id = replaced?.id ?? randomUUID()
@@ -363,7 +363,14 @@ export class WorkspaceGateway {
     })
     await handshake
   }
-  rpc(method: string, params: Record<string, unknown>): Promise<any> {
+  onTrace:(entry:{direction:'request'|'response'|'error';method:string;requestId:string;durationMs?:number;data:unknown})=>void=()=>{}
+  async rpc(method:string,params:Record<string,unknown>):Promise<any>{
+    const requestId=randomUUID(),started=Date.now()
+    this.onTrace({direction:'request',method,requestId,data:params})
+    try{const result=await this.rpcRaw(method,params);this.onTrace({direction:'response',method,requestId,durationMs:Date.now()-started,data:result});return result}
+    catch(error){this.onTrace({direction:'error',method,requestId,durationMs:Date.now()-started,data:{error:error instanceof Error?error.message:'请求失败'}});throw error}
+  }
+  private rpcRaw(method: string, params: Record<string, unknown>): Promise<any> {
     if(this.runner)return this.runner.rpc(method,params)
     if (this.paired) return this.paired.rpc(method, params)
     if (this.socket?.readyState !== WebSocket.OPEN)

+ 38 - 0
src/server/workspaceInspector.ts

@@ -0,0 +1,38 @@
+import Router from '@koa/router'
+import {randomUUID} from 'node:crypto'
+import type {WorkspaceStore} from './workspaceStore.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {WorkspaceInspectorEntry} from '../shared/workspacePanels.js'
+
+export function redactInspector(value:unknown,depth=0):unknown {
+  if(depth>8)return '[深度限制]'
+  if(typeof value==='string')return value.replace(/Bearer\s+[^\s"']+/gi,'Bearer [已隐藏]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g,'[已隐藏凭据]').replace(/([?&](?:token|key|secret|password)=)[^&\s]+/gi,'$1[已隐藏]').slice(0,16000)
+  if(Array.isArray(value))return value.slice(0,80).map(v=>redactInspector(v,depth+1))
+  if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).slice(0,80).map(([key,item])=>[key,/(?:authorization|cookie|password|secret|credential|access.?token|refresh.?token|api.?key|content_base64|data_url|sealed|tokenHash)/i.test(key)||key==='token'?'[已隐藏]':redactInspector(item,depth+1)]))
+  return value
+}
+export class WorkspaceInspector {
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore){
+    store.db.exec('CREATE TABLE IF NOT EXISTS workspace_inspector(seq INTEGER PRIMARY KEY AUTOINCREMENT,owner TEXT NOT NULL,conversation_id TEXT NOT NULL,data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS workspace_inspector_scope ON workspace_inspector(owner,conversation_id,seq)')
+  }
+  record(owner:string,conversationId:string,entry:Omit<WorkspaceInspectorEntry,'id'|'at'>){
+    try {
+      const value={...entry,data:redactInspector(entry.data),id:randomUUID(),at:Date.now()}
+      let data=JSON.stringify(value)
+      if(Buffer.byteLength(data)>64000)data=JSON.stringify({...value,data:{truncated:true,preview:JSON.stringify(value.data).slice(0,14000)}})
+      this.store.db.prepare('INSERT INTO workspace_inspector(owner,conversation_id,data) VALUES(?,?,?)').run(owner,conversationId,data)
+      this.store.db.prepare('DELETE FROM workspace_inspector WHERE owner=? AND conversation_id=? AND seq NOT IN (SELECT seq FROM workspace_inspector WHERE owner=? AND conversation_id=? ORDER BY seq DESC LIMIT 2000)').run(owner,conversationId,owner,conversationId)
+    }catch{ /* Inspection must never interrupt a running conversation. */ }
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/conversations/:id/inspector',ctx=>{
+      const owner=this.auth.require(ctx).id;this.store.require(owner,'conversation',ctx.params.id)
+      const taskId=typeof ctx.query.taskId==='string'?ctx.query.taskId:undefined
+      if(taskId)this.store.requireTask(owner,ctx.params.id,taskId)
+      const entries=this.store.db.prepare('SELECT data FROM workspace_inspector WHERE owner=? AND conversation_id=? ORDER BY seq DESC LIMIT 2000').all(owner,ctx.params.id).map(row=>JSON.parse(String(row.data)) as WorkspaceInspectorEntry).reverse().filter(e=>!taskId||e.taskId===taskId).slice(-400)
+      ctx.set('Cache-Control','no-store');ctx.body={entries,retention:2000}
+    })
+    return router
+  }
+}

+ 11 - 11
src/server/workspaceRemoteAgents.ts

@@ -30,25 +30,25 @@ export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes,
     }
     ctx.set('Cache-Control','no-store')
     if (!agentId) {
-      if (ctx.method!=='GET') throw new HttpError(405,'只允许读取远端 Agent','method_not_allowed')
+      if (ctx.method!=='GET') throw new HttpError(405,'只允许读取远端机器人','method_not_allowed')
       const agents=store.list<WorkspaceAgent>(owner,'agent').filter(a=>!a.archived)
       for(const value of agents) if(value.remoteAgentId) void nodes.refreshRemoteAgent(owner,value).catch(()=>{})
       ctx.body={agents:agents.map(a=>store.agentSummary(a))};return
     }
     let agent=store.require<WorkspaceAgent>(owner,'agent',agentId)
-    if (agent.archived) throw new HttpError(410,'远端 Agent 已归档','remote_agent_archived')
+    if (agent.archived) throw new HttpError(410,'远端机器人已归档','remote_agent_archived')
     if (agent.remoteAgentId) agent=await nodes.refreshRemoteAgent(owner,agent)
     if (!path) {
       if (ctx.method!=='GET') throw new HttpError(405,'远端配置只读','method_not_allowed')
       ctx.body={agent:store.agentSummary(agent)};return
     }
     const hops=Number(ctx.get('x-yaoyao-agent-hops')||0)
-    if (!Number.isInteger(hops) || hops<0 || hops>=8) throw new HttpError(508,'远端 Agent 引用链过长或循环','agent_reference_loop')
+    if (!Number.isInteger(hops) || hops<0 || hops>=8) throw new HttpError(508,'远端机器人引用链过长或循环','agent_reference_loop')
     const target=nodes.targetForAgent(owner,agent), scope=`${device}:${agentId}`
     const bindingKey=(id:string)=>`${scope}:${id}`
     const requireBinding=(id:string)=>{
       const binding=store.get<{profile:string}>(owner,'agent-export-binding',bindingKey(id))
-      if (!binding || binding.profile!==agent.profile) throw new HttpError(403,'会话不属于这个 Agent 引用','agent_session_forbidden')
+      if (!binding || binding.profile!==agent.profile) throw new HttpError(403,'会话不属于这个机器人引用','agent_session_forbidden')
     }
     const transform=(frame:Record<string,any>)=>{
       if (!allowedCommands.has(frame.method)) throw new HttpError(403,'不能通过引用修改远端配置','remote_agent_read_only')
@@ -60,7 +60,7 @@ export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes,
       }
       if ((frame.method==='prompt.submit'||frame.method==='session.steer') && !agent.remoteAgentId) {
         if (typeof params.text!=='string') throw new HttpError(400,'消息内容无效','invalid_prompt')
-        params.text=`你是 ${agent.name}。远端 Agent 的角色与规则(版本 ${agent.revision}):\n${agent.instructions}\n\n${params.text}`
+        params.text=`你是 ${agent.name}。远端机器人的角色与规则(版本 ${agent.revision}):\n${agent.instructions}\n\n${params.text}`
       }
       const id=ctx.get('idempotency-key')
       if (id) store.put(owner,'agent-export-command',bindingKey(id),{method:frame.method,profile:agent.profile})
@@ -80,10 +80,10 @@ export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes,
     if (path.startsWith('/api/realtime/')) {
       const selectedChannel=/^\/api\/realtime\/channels\/([0-9a-f-]{36})/.exec(path)?.[1]
       if (selectedChannel && !store.get(owner,'agent-export-channel',bindingKey(selectedChannel)))
-        throw new HttpError(403,'通道不属于这个 Agent 引用','agent_channel_forbidden')
+        throw new HttpError(403,'通道不属于这个机器人引用','agent_channel_forbidden')
       const receiptID=/^\/api\/realtime\/commands\/([^/]+)$/.exec(path)?.[1]
       if (receiptID && !store.get(owner,'agent-export-command',bindingKey(receiptID)))
-        throw new HttpError(403,'命令不属于这个 Agent 引用','agent_command_forbidden')
+        throw new HttpError(403,'命令不属于这个机器人引用','agent_command_forbidden')
       const rememberChannel=(body:any)=>{
         if(ctx.method==='POST' && path==='/api/realtime/channels' && typeof body?.id==='string')
           store.put(owner,'agent-export-channel',bindingKey(body.id),{createdAt:Date.now()})
@@ -96,14 +96,14 @@ export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes,
         return
       }
       const allowed=/^\/api\/realtime\/(capabilities|channels(?:\/[0-9a-f-]{36}(?:\/(events|commands))?)?|commands\/[A-Za-z0-9:_-]{1,200})$/.test(path)
-      if (!allowed) throw new HttpError(404,'Agent 实时路径不存在','not_found')
+      if (!allowed) throw new HttpError(404,'机器人实时路径不存在','not_found')
       let body: any
       if (ctx.method==='POST') {
         let size=0;const parts:Buffer[]=[]
         for await(const part of ctx.req) {size+=part.length;if(size>36*1024*1024) throw new HttpError(413,'请求过大','body_too_large');parts.push(Buffer.from(part))}
         try {body=JSON.parse(Buffer.concat(parts).toString())} catch {throw new HttpError(400,'JSON 无效','invalid_json')}
         if(path.endsWith('/commands')) body=transform(body)
-        else if(body.channel!=='chat') throw new HttpError(403,'Agent 仅支持聊天通道','invalid_channel')
+        else if(body.channel!=='chat') throw new HttpError(403,'机器人仅支持聊天通道','invalid_channel')
       }
       const abort=new AbortController(), timer=setTimeout(()=>abort.abort(),30_000)
       ctx.res.once('close',()=>abort.abort())
@@ -125,11 +125,11 @@ export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes,
       else if(response.status!==204) {ctx.body=await response.json();rememberChannel(ctx.body);remember(ctx.body)}
       return
     }
-    if (ctx.method!=='GET') throw new HttpError(405,'Agent 引用只允许读取文件与会话','method_not_allowed')
+    if (ctx.method!=='GET') throw new HttpError(405,'机器人引用只允许读取文件与会话','method_not_allowed')
     pairings.authorize(device,token,'history.read')
     const history=/^\/api\/sessions\/([^/]+)\/messages$/.exec(path)
     if (history) requireBinding(decodeURIComponent(history[1]!))
-    else if(path!=='/api/files/download') throw new HttpError(403,'路径不属于 Agent 引用','agent_path_forbidden')
+    else if(path!=='/api/files/download') throw new HttpError(403,'路径不属于机器人引用','agent_path_forbidden')
     const query=new URLSearchParams(ctx.querystring);query.set('profile',agent.profile)
     const response=await target.session.request(path,{search:query,maxResponseBytes:25*1024*1024})
     ctx.status=response.status

+ 14 - 6
src/server/workspaceRoutes.ts

@@ -4,7 +4,7 @@ import Router from '@koa/router'
 import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
 import { z } from 'zod'
-import { WorkspaceStore, agentInput, parse } from './workspaceStore.js'
+import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
 import { WorkspaceRuntime } from './workspaceRuntime.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import {
@@ -100,7 +100,7 @@ export function workspaceRouter(
     nodes.requireSource(user, input)
     const sources = await nodes.sources(user)
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
-      throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
+      throw new HttpError(409, '基础机器人当前不可用', 'source_unavailable')
     nodes.requireSource(user, input)
     if (input.canManageTeam || input.execution==='computer') await runtime.teamTools.requireAvailable(user, input)
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
@@ -108,17 +108,25 @@ export function workspaceRouter(
     ctx.status = 201
   })
   router.patch('/api/app/agents/:id', async (ctx) => {
-    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = body(ctx)
+    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = parse(agentPatch,body(ctx))
     const authorization = auth.pushAuthorizationVersion(user)
-    nodes.requireSource(user, agent)
-    if ((input.canManageTeam === true && agent.canManageTeam !== true) || input.execution==='computer') await runtime.teamTools.requireAvailable(user, {...agent,...(input.execution?{execution:input.execution}:{})})
+    const updated={...agent,...input}
+    const sourceChanged=updated.nodeId!==agent.nodeId||updated.profile!==agent.profile
+    nodes.requireSource(user,updated)
+    if(sourceChanged){
+      const sources=await nodes.sources(user)
+      if(!sources.sources.some(s=>s.nodeId===updated.nodeId&&s.profile===updated.profile))throw new HttpError(409,'基础机器人当前不可用','source_unavailable')
+    }
+    if ((input.canManageTeam === true && agent.canManageTeam !== true) || input.execution==='computer' || (sourceChanged&&(updated.canManageTeam||updated.execution==='computer'))) await runtime.teamTools.requireAvailable(user, updated)
+    if(sourceChanged&&runtime.cloud?.selected(user,updated))await runtime.cloud.requireAvailable(user,updated,nodes.target(user,updated.nodeId))
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
-    nodes.requireSource(user, agent)
+    nodes.requireSource(user, updated)
     ctx.body = { agent: store.agentSummary(store.atomic(() => {
       if (input.archived === true && !agent.temporaryGoalId) {
         const direct = store.list<WorkspaceConversation>(user, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)
         if (direct) store.changeConversationLifecycle(user, direct.id, 'archive')
       }
+      nodes.requireSource(user,{...store.require<WorkspaceAgent>(user,'agent',ctx.params.id),...input})
       return store.updateAgent(user, ctx.params.id, input)
     })) }
   })

+ 86 - 0
src/server/workspaceRoutines.ts

@@ -0,0 +1,86 @@
+import Router from '@koa/router'
+import {randomUUID} from 'node:crypto'
+import {z} from 'zod'
+import {parse,type WorkspaceStore} from './workspaceStore.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {WorkspaceNodes} from './workspaceGateway.js'
+import type {WorkspaceRuntime} from './workspaceRuntime.js'
+import type {WorkspaceAgent,WorkspaceConversation,WorkspaceRun} from '../shared/workspace.js'
+import type {WorkspaceRoutine,WorkspaceSchedule,WorkspaceRoutineRun} from '../shared/workspacePanels.js'
+import {HttpError} from './errors.js'
+const timezone=z.string().max(100).refine(value=>{try{new Intl.DateTimeFormat('en',{timeZone:value});return true}catch{return false}},'时区无效')
+const clock=z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/)
+const schedule=z.discriminatedUnion('kind',[
+  z.object({kind:z.literal('once'),timezone,at:z.number().int().positive()}).strict(),
+  z.object({kind:z.literal('interval'),timezone,everyMinutes:z.number().int().min(1).max(525600)}).strict(),
+  z.object({kind:z.literal('daily'),timezone,time:clock}).strict(),
+  z.object({kind:z.literal('weekly'),timezone,time:clock,weekdays:z.array(z.number().int().min(0).max(6)).min(1).max(7)}).strict(),
+])
+const input=z.object({name:z.string().trim().min(1).max(100),prompt:z.string().trim().min(1).max(24000),enabled:z.boolean(),schedule}).strict()
+export function nextRoutineAt(s:WorkspaceSchedule,after:number):number|undefined {
+  if(s.kind==='once')return s.at!>after?s.at:undefined
+  if(s.kind==='interval')return after+s.everyMinutes!*60000
+  const formatter=new Intl.DateTimeFormat('en-US',{timeZone:s.timezone,hourCycle:'h23',hour:'2-digit',minute:'2-digit',weekday:'short'})
+  for(let at=Math.floor(after/60000)*60000+60000;at<=after+8*86400000;at+=60000){
+    const parts=Object.fromEntries(formatter.formatToParts(at).map(p=>[p.type,p.value]))
+    if(`${parts.hour}:${parts.minute}`===s.time&&(s.kind==='daily'||s.weekdays!.includes(['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday!))))return at
+  }
+  throw new HttpError(400,'无法计算下次执行时间','routine_schedule_invalid')
+}
+const terminal=(status:string)=>['complete','failed','interrupted','skipped'].includes(status)
+export class WorkspaceRoutines {
+  private timer?:ReturnType<typeof setInterval>;private ticking=false;private closed=false
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly runtime:WorkspaceRuntime){}
+  private agent(owner:string,id:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',id);if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId)throw new HttpError(409,'此机器人不能建立定时任务','routine_agent_unavailable');this.nodes.requireSource(owner,agent);return agent}
+  save(owner:string,agentId:string,body:unknown,id:string=randomUUID()){
+    this.agent(owner,agentId);const value=parse(input,body),old=this.store.get<WorkspaceRoutine>(owner,'routine',id)
+    if(old&&old.agentId!==agentId)throw new HttpError(404,'定时任务不存在','not_found')
+    const now=Date.now(),unchanged=!!old&&old.enabled===value.enabled&&JSON.stringify(parse(schedule,old.schedule))===JSON.stringify(value.schedule),nextAt=unchanged?old!.nextAt:value.enabled?nextRoutineAt(value.schedule,now):undefined
+    if(value.enabled&&!nextAt)throw new HttpError(400,'请选择未来的执行时间','routine_time_invalid')
+    const routine:WorkspaceRoutine={...value,id,agentId,nextAt,createdAt:old?.createdAt??now,updatedAt:now,lastAt:old?.lastAt}
+    this.store.put(owner,'routine',id,routine);this.store.event(owner,'routine.changed',routine);return routine
+  }
+  runs(owner:string,agentId:string){return this.store.list<WorkspaceRoutineRun>(owner,'routine-run').filter(r=>r.agentId===agentId).map(r=>{
+    const run=r.runId?this.store.get<WorkspaceRun>(owner,'run',r.runId):undefined
+    return run?{...r,status:run.status,error:run.error}:r
+  }).sort((a,b)=>b.startedAt-a.startedAt).slice(0,100)}
+  run(owner:string,routine:WorkspaceRoutine,scheduledAt:number,requestId:string=randomUUID()){
+    return this.store.command(owner,requestId,{operation:'routine.run',routineId:routine.id,scheduledAt},()=>this.store.atomic(()=>{
+      this.agent(owner,routine.agentId)
+      const id=randomUUID(),entry:WorkspaceRoutineRun={id,routineId:routine.id,agentId:routine.agentId,scheduledAt,startedAt:Date.now(),status:'queued'}
+      try {
+        if(this.store.list<WorkspaceRoutineRun>(owner,'routine-run').some(r=>r.routineId===routine.id&&!terminal((r.runId?this.store.get<WorkspaceRun>(owner,'run',r.runId)?.status:undefined)??r.status)))throw new HttpError(409,'上次定时任务仍在执行,本次已跳过','routine_overlap')
+        const conversation=this.store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&!c.archived&&c.memberIds[0]===routine.agentId)
+        if(!conversation)throw new HttpError(409,'机器人的聊天已归档或删除','routine_conversation_unavailable')
+        const run=this.runtime.send(owner,conversation.id,{requestId:id,content:routine.prompt})
+        Object.assign(entry,{runId:run.id,conversationId:conversation.id})
+      }catch(error){entry.status=error instanceof HttpError&&error.code==='routine_overlap'?'skipped':'failed';entry.error=error instanceof Error?error.message:'定时任务未能启动'}
+      this.store.put(owner,'routine-run',id,entry)
+      const old=this.store.list<WorkspaceRoutineRun>(owner,'routine-run').filter(r=>r.agentId===routine.agentId).sort((a,b)=>b.startedAt-a.startedAt).slice(100)
+      for(const run of old)if(terminal(this.store.get<WorkspaceRun>(owner,'run',run.runId??'')?.status??run.status))this.store.remove(owner,'routine-run',run.id)
+      return entry
+    }))
+  }
+  tick(now=Date.now()){
+    if(this.closed||this.ticking)return;this.ticking=true
+    try {for(const owner of this.store.owners())for(const routine of this.store.list<WorkspaceRoutine>(owner,'routine')){
+      if(!routine.enabled||!routine.nextAt||routine.nextAt>now)continue
+      this.store.atomic(()=>{
+        const due=routine.nextAt!
+        try{if(!this.auth.isUserActive(owner))throw new Error('账号当前不可用');this.run(owner,routine,due)}catch(error){const id=randomUUID();routine.enabled=false;this.store.put(owner,'routine-run',id,{id,routineId:routine.id,agentId:routine.agentId,scheduledAt:due,startedAt:now,status:'failed',error:error instanceof Error?error.message:'定时任务未能启动'})}
+        routine.lastAt=due;routine.nextAt=routine.enabled?nextRoutineAt(routine.schedule,now):undefined;if(!routine.nextAt)routine.enabled=false
+        routine.updatedAt=now;this.store.put(owner,'routine',routine.id,routine);this.store.event(owner,'routine.changed',routine)
+      })
+    }}finally{this.ticking=false}
+  }
+  start(){if(this.timer)return;this.tick();this.timer=setInterval(()=>this.tick(),10000);this.timer.unref()}
+  close(){this.closed=true;clearInterval(this.timer)}
+  router(){const router=new Router()
+    router.get('/api/app/agents/:id/routines',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.body={routines:this.store.list<WorkspaceRoutine>(owner,'routine').filter(r=>r.agentId===ctx.params.id),runs:this.runs(owner,ctx.params.id)}})
+    router.post('/api/app/agents/:id/routines',ctx=>{ctx.body={routine:this.save(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body)};ctx.status=201})
+    router.put('/api/app/agents/:id/routines/:routineId',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'routine',ctx.params.routineId);ctx.body={routine:this.save(owner,ctx.params.id,(ctx.request as any).body,ctx.params.routineId)}})
+    router.delete('/api/app/agents/:id/routines/:routineId',ctx=>{const owner=this.auth.require(ctx).id,r=this.store.require<WorkspaceRoutine>(owner,'routine',ctx.params.routineId);if(r.agentId!==ctx.params.id)throw new HttpError(404,'定时任务不存在','not_found');this.store.remove(owner,'routine',r.id);ctx.body={ok:true}})
+    router.post('/api/app/agents/:id/routines/:routineId/run',ctx=>{const owner=this.auth.require(ctx).id,r=this.store.require<WorkspaceRoutine>(owner,'routine',ctx.params.routineId);if(r.agentId!==ctx.params.id)throw new HttpError(404,'定时任务不存在','not_found');const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body);ctx.body={run:this.run(owner,r,0,body.requestId)}})
+    return router
+  }
+}

+ 31 - 19
src/server/workspaceRuntime.ts

@@ -1,3 +1,4 @@
+import {GROK_COMPUTER_TOOLS,GROK_COMPUTER_RULES,type GrokCloud} from './grokCloud.js'
 import { randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import { z } from 'zod'
@@ -63,6 +64,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   get idleForUpdate(): boolean {
     return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
   }
+  cloud?:GrokCloud
+  inspector?:import('./workspaceInspector.js').WorkspaceInspector
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
   readonly tasks: WorkspaceTaskCoordinator
@@ -190,10 +193,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     },authorize:()=>{
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
-      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
+      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮机器人或任务授权已结束','run_authorization_revoked')
     }})
+    gateway.onTrace=entry=>this.inspector?.record(owner,c.id,{...entry,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
-    const movedRunner=!!binding&&(binding.computerEnvironmentId!==agent.computerEnvironmentId||binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
+    const movedRunner=!!this.store.get(owner,'binding-reset',key)||!!binding&&(binding.computerEnvironmentId!==agent.computerEnvironmentId||binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
     if(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
@@ -301,6 +305,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       finish(completedEvidence ? undefined : new Error('Hermes 连接断开'))
     gateway.onEvent = (frame: GatewayFrame) => {
       if (settled || !runtimeId || frame.session_id !== runtimeId) return
+      this.inspector?.record(owner,c.id,{direction:'event',method:frame.type,data:frame.payload,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
       const p = frame.payload ?? {},
         type = frame.type
       if(type==='computer.paused'||type==='computer.resumed'){const current=this.getWork(owner,run.id);current.status=type==='computer.paused'?'waiting':'running';this.saveWork(owner,current);return}
@@ -535,22 +540,27 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       } else {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
-        // Only explicit grants mount the bridge; ordinary Bot turns never query plugins.
-        if (agent.canManageTeam === true && !this.store.require<Run>(owner, 'run', run.runId).assignmentId) {
-          const granted = this.getWork(owner, run.id)
-          granted.teamManagementRevision = agent.revision
-          this.saveWork(owner, granted)
-          await this.teamTools.requireAvailable(owner, agent)
-          toolLease = await createWorkspaceToolLease({
-            target, profile: agent.profile, workId: run.id, signal: toolController.signal,
-            session: () => ({ runtimeId, storedId: binding!.storedId }),
-            assertActive: () => { if (settled || this.closing) throw new Error('运行已停止'); this.teamTools.assertTurn(owner, run.id) },
-            catalog: () => this.teamTools.catalog(owner, run.id),
-            call: (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)) },
+        const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
+        const cloud=!!this.cloud?.selected(owner,agent)
+        if(team||cloud){
+          if(team){
+            const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)
+            await this.teamTools.requireAvailable(owner,agent)
+          }
+          if(cloud)await this.cloud!.requireAvailable(owner,agent,target)
+          const assertActive=()=>{
+            if(settled||this.closing||toolController.signal.aborted)throw new Error('运行已停止')
+            this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,this.store.require<Agent>(owner,'agent',agent.id))
+            if(team)this.teamTools.assertTurn(owner,run.id)
+          }
+          toolLease=await createWorkspaceToolLease({
+            target,profile:agent.profile,workId:run.id,signal:toolController.signal,
+            session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(cloud?GROK_COMPUTER_TOOLS:[])],
+            call:async(toolId,args)=>{assertActive();return toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):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
+          await toolLease.bind();if(settled)return await completion
         }
         await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
         const trigger = this.store.require<Message>(owner, 'message', run.messageId)
@@ -576,7 +586,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         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 rules = [
-          `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端 Agent 管理。' : agent.instructions}`,
+          `你是 ${agent.name}。以下是用户为这个独立机器人配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端机器人管理。' : agent.instructions}`,
           c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
             ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
                 const role = c.memberRoles?.[member.id]
@@ -592,7 +602,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           this.store.require<Run>(owner, 'run', run.runId).internalInstruction || '',
           goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
           agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
-          toolLease ? TEAM_TOOL_RULES : '',
+          team ? TEAM_TOOL_RULES : '',
+          cloud ? GROK_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
           .filter(Boolean)
@@ -617,6 +628,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             ...(target.runner?.computer?{workMarker:`[yaoyao-run:${run.runId}:${resultMessage.id}]`}:{}),
             text: `${rules}\n\n${text}\n${attachmentRefs.join('\n')}`,
           })
+          this.store.remove(owner,'binding-reset',key)
           binding.contextSeq = Math.max(binding.contextSeq ?? 0, admission.contextThroughSeq)
           this.store.put(owner, 'binding', key, binding)
         } catch (error) {

+ 1 - 1
src/server/workspaceScheduler.ts

@@ -5,7 +5,7 @@ import { mentionedAgents } from './workspaceMentions.js'
 import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceMessage as Message, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
 
 export const NO_REPLY = '[[YAOYAO_NO_REPLY_V1]]'
-export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的 Agent。'
+export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的机器人。'
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 export interface Work {
   resourceWait?:boolean

+ 20 - 6
src/server/workspaceStore.ts

@@ -39,6 +39,7 @@ export const agentInput = z
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     execution:z.enum(['profile','computer']).default('profile'),
+    computer:z.enum(['auto','cloud','vm','off']).optional(),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
@@ -50,6 +51,9 @@ export const agentPatch = z
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     execution:z.enum(['profile','computer']).optional(),
+    computer:z.enum(['auto','cloud','vm','off']).optional(),
+    nodeId:z.string().min(1).max(256).optional(),
+    profile:z.string().min(1).max(256).optional(),
     canManageTeam: z.boolean().optional(),
     archived: z.boolean().optional(),
   })
@@ -301,7 +305,7 @@ export class WorkspaceStore {
           (a) => a.name.toLocaleLowerCase() === body.name.toLocaleLowerCase(),
         )
       )
-        throw new HttpError(409, 'Agent 名称已存在', 'duplicate_agent_name')
+        throw new HttpError(409, '机器人名称已存在', 'duplicate_agent_name')
       const now = Date.now(),
         id = randomUUID()
       const agent: Agent = {
@@ -348,22 +352,30 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      const sourceChanged=(patch.nodeId!==undefined&&patch.nodeId!==agent.nodeId)||(patch.profile!==undefined&&patch.profile!==agent.profile)
+      const destinationChanged=patch.computer!==undefined&&patch.computer!==agent.computer
+      if(sourceChanged||destinationChanged){
+        if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
+        if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')
+        if(sourceChanged&&agent.computerEnvironmentId){const shared=this.require<{nodeId:string;profile:string;managedLocalVm?:boolean;managedCompose?:boolean}>(owner,'shared-computer',agent.computerEnvironmentId);if((patch.nodeId??agent.nodeId)!==shared.nodeId||(!shared.managedLocalVm&&!shared.managedCompose&&shared.profile!=='*'&&(patch.profile??agent.profile)!==shared.profile))throw new HttpError(409,'请先解除电脑共享,再切换到其他来源','shared_computer_active')}
+      }
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
       if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
       if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
-        throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
+        throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')
       if (
         patch.name &&
         this.list<Agent>(owner, 'agent').some(
           (a) => a.id !== id && a.name.toLocaleLowerCase() === patch.name!.toLocaleLowerCase(),
         )
       )
-        throw new HttpError(409, 'Agent 名称已存在', 'duplicate_agent_name')
+        throw new HttpError(409, '机器人名称已存在', 'duplicate_agent_name')
       const next = { ...agent, ...patch, revision: agent.revision + 1, updatedAt: Date.now() }
       if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
         next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
       this.put(owner, 'agent', id, next)
+      if(sourceChanged)for(const binding of this.list<{id:string}>(owner,'binding').filter(binding=>binding.id.endsWith(`:${id}`))){this.put(owner,'binding-reset',binding.id,{id:binding.id});this.remove(owner,'binding',binding.id)}
       this.event(owner, 'agent.changed', next)
       for (const c of this.list<Conversation>(owner, 'conversation').filter(
         (c) => c.kind === 'direct' && c.memberIds[0] === id,
@@ -445,6 +457,7 @@ export class WorkspaceStore {
         throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
       for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
         this.deleteConversation(owner, conversation.id, true)
+      for(const kind of ['routine','routine-run'])for(const item of this.list<{id:string;agentId:string}>(owner,kind).filter(item=>item.agentId===id))this.remove(owner,kind,item.id)
       this.remove(owner, 'agent', id)
       this.event(owner, 'agent.deleted', { id })
     })
@@ -471,6 +484,7 @@ export class WorkspaceStore {
       this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind IN ('message','run','turn','interaction','interaction-binding','binding','conversation-task','goal','assignment') AND json_extract(data,'$.conversationId')=?").run(owner, id)
       this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationId')=?").run(owner, id)
       this.remove(owner, 'context', id)
+      if(this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workspace_inspector'").get())this.db.prepare('DELETE FROM workspace_inspector WHERE owner=? AND conversation_id=?').run(owner,id)
       this.remove(owner, 'conversation', id)
       this.event(owner, 'conversation.deleted', { id }, id)
     })
@@ -488,7 +502,7 @@ export class WorkspaceStore {
     for (const id of ids){
       const agent=this.require<Agent>(owner,'agent',id)
       if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入持久团队','helper_task_bound')
-      if(agent.archived)throw new HttpError(409,'已归档 Agent 不能加入新群','agent_archived')
+      if(agent.archived)throw new HttpError(409,'已归档机器人不能加入新群','agent_archived')
     }
     const now = Date.now()
     const c: Conversation = {
@@ -735,7 +749,7 @@ export class WorkspaceStore {
     const patch = parse(conversationPatch, input),
       c = this.require<Conversation>(owner, 'conversation', id)
     if (c.kind === 'direct' && Object.keys(patch).some((k) => k !== 'pinned'))
-      throw new HttpError(400, '请编辑 Agent 资料', 'edit_agent_instead')
+      throw new HttpError(400, '请编辑机器人资料', 'edit_agent_instead')
     const memberIds = patch.memberIds ?? c.memberIds
     const members = new Set(memberIds)
     if (!members.has(c.administratorId))
@@ -751,7 +765,7 @@ export class WorkspaceStore {
       const agent = this.require<Agent>(owner, 'agent', memberId)
       if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入其他群聊','helper_task_bound')
       if (!c.memberIds.includes(memberId) && agent.archived)
-        throw new HttpError(409, '已归档 Agent 不能加入群聊', 'agent_archived')
+        throw new HttpError(409, '已归档机器人不能加入群聊', 'agent_archived')
     }
     const next = {
       ...c, ...patch, memberIds,

+ 12 - 12
src/server/workspaceTeamTools.ts

@@ -33,12 +33,12 @@ const archiveCreatedAgent = z.object({requestId:uuid,agentId:uuid,confirmName:z.
 const editTeam = z.object({requestId:uuid,teamId:uuid,name:z.string().trim().min(1).max(100).optional(),instructions:z.string().max(24000).optional(),memberIds:z.array(uuid).min(2).max(8).optional(),archived:z.boolean().optional()}).strict()
 
 const definitions = [
-  ['workspace_list_sources', '查看当前账号获准使用的基础 Agent。创建成员前先查询。', empty],
-  ['workspace_list_agents', '查看当前账号可复用的成员,优先复用已有 Agent,避免重复创建。', empty],
+  ['workspace_list_sources', '查看当前账号获准使用的基础机器人。创建成员前先查询。', empty],
+  ['workspace_list_agents', '查看当前账号可复用的成员,优先复用已有机器人,避免重复创建。', empty],
   ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
-  ['workspace_create_agent', '创建持久保存的 Bot Agent,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
+  ['workspace_create_agent', '创建持久保存的 Bot 机器人,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
   ['workspace_create_helper','为当前团队目标创建临时隔离助手并立即分派一个具体子任务。助手只参与此任务、不创建单聊、不获得组队权限;目标结束后退役。每个目标最多 8 个有效临时助手。优先用于一次性工作。',createHelper],
-  ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实 Agent ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
+  ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实机器人 ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
   ['workspace_start_team_task', '在你管理的团队启动独立任务,由管理员分派并复核。立即返回任务编号,不表示完成;请向用户提供团队名称和任务名称。requestId 使用新的 UUID,重试时复用。', startTask],
   ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
   ['workspace_assign_task', '为团队目标创建有负责人、依赖和验收要求的子任务。仅可分派给团队内其他成员,依赖必须先完成复核。requestId 为重试复用的 UUID。', assignmentInput],
@@ -52,7 +52,7 @@ const definitions = [
   ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
 ] as const
 
-export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。需要长期复用时创建持久成员;一次性子任务可使用 workspace_create_helper 创建任务级临时隔离助手。每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
+export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础机器人、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。需要长期复用时创建持久成员;一次性子任务可使用 workspace_create_helper 创建任务级临时隔离助手。每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
 
 /** The caller identity comes exclusively from the running server-side turn. */
 export class WorkspaceTeamTools {
@@ -61,7 +61,7 @@ export class WorkspaceTeamTools {
   async requireAvailable(owner: string, agent: Pick<Agent, 'nodeId' | 'profile' | 'remoteAgentId' | 'execution'>): Promise<void> {
     this.nodes.requireSource(owner, agent)
     if ((agent.nodeId !== 'local' && !this.nodes.runnerTarget?.(owner,agent.nodeId)) || agent.remoteAgentId)
-      throw new HttpError(409, '组队发起者需要使用与 Web 服务同机的基础 Agent;远程 Agent 可作为团队成员。', 'team_tools_local_required')
+      throw new HttpError(409, '组队发起者需要使用与 Web 服务同机的基础机器人;远程机器人可作为团队成员。', 'team_tools_local_required')
     if(agent.execution==='computer'){
       const target=this.nodes.runnerTarget?.(owner,agent.nodeId)
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
@@ -83,7 +83,7 @@ export class WorkspaceTeamTools {
       || (root.authorizationVersion !== undefined && root.authorizationVersion !== this.runtime.authorizationVersion(owner))
       || c.archived || !c.memberIds.includes(agent.id) || work.cancelRequested || root.stopRequested
       || !['running', 'waiting'].includes(work.status))
-      throw new HttpError(403, '当前 Agent 的本轮组队授权已失效', 'team_tools_forbidden')
+      throw new HttpError(403, '当前机器人的本轮组队授权已失效', 'team_tools_forbidden')
     return { agent, work }
   }
 
@@ -101,7 +101,7 @@ export class WorkspaceTeamTools {
   private team(owner: string, agentId: string, teamId: string): Conversation {
     const c = this.store.require<Conversation>(owner, 'conversation', teamId)
     if (c.kind !== 'group' || c.archived || c.administratorId !== agentId || !c.memberIds.includes(agentId))
-      throw new HttpError(403, '只能操作由当前 Agent 担任管理员的有效团队', 'team_tools_forbidden')
+      throw new HttpError(403, '只能操作由当前机器人担任管理员的有效团队', 'team_tools_forbidden')
     for (const id of c.memberIds) {
       const member = this.store.require<Agent>(owner, 'agent', id)
       this.nodes.requireSource(owner, member)
@@ -154,17 +154,17 @@ export class WorkspaceTeamTools {
     if (toolId === 'workspace_create_agent') {
       const { requestId, ...body } = parse(createAgent, input)
       body.execution??=agent.execution??'profile'
-      if(agent.execution==='computer'&&body.execution!=='computer')throw new HttpError(403,'隔离 Agent 创建的成员必须使用隔离电脑','computer_scope_escalation')
+      if(agent.execution==='computer'&&body.execution!=='computer')throw new HttpError(403,'隔离机器人创建的成员必须使用隔离电脑','computer_scope_escalation')
       this.nodes.requireSource(owner, body)
       const sources = await this.nodes.sources(owner)
       this.assertTurn(owner, workId)
       this.nodes.requireSource(owner, body)
       if (!sources.sources.some(s => s.nodeId === body.nodeId && s.profile === body.profile))
-        throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
+        throw new HttpError(409, '基础机器人当前不可用', 'source_unavailable')
       if(body.execution==='computer'){await this.requireAvailable(owner,body);this.assertTurn(owner,workId)}
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
         if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
-          throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')
+          throw new HttpError(409, '自动组队最多保留 100 个有效机器人,请先复用或归档已有成员。', 'team_agent_limit')
         const created = this.store.createAgent(owner, { ...body, canManageTeam: false }, { createdByAgentId: agent.id, createdFromRunId: work.runId })
         return { agent: this.store.agentSummary(created) }
       })
@@ -199,7 +199,7 @@ export class WorkspaceTeamTools {
           : this.store.createTask(owner, team.id, { title: body.title })
         const goal = this.runtime.tasks.begin(owner, task, agent, body.content,
           { conversationId: work.conversationId, conversationTaskId: work.conversationTaskId, runId: work.runId, agentId: agent.id }, body.acceptanceCriteria)
-        const run = this.runtime.dispatch(owner, team.id, { requestId: randomUUID(), taskId: task.id, content: `由 Agent「${agent.name}」发起的团队任务:\n${body.content}` }, { agentId: agent.id, kind: 'task_review' })
+        const run = this.runtime.dispatch(owner, team.id, { requestId: randomUUID(), taskId: task.id, content: `由机器人「${agent.name}」发起的团队任务:\n${body.content}` }, { agentId: agent.id, kind: 'task_review' })
         return { teamId: team.id, teamName: team.name, task: { ...task, goal }, run, message: '任务已启动,尚未完成。执行结果会回到原会话,由管理员复核。' }
       })
     }

+ 2 - 1
src/server/workspaceToolLease.ts

@@ -31,7 +31,7 @@ export async function requireTeamToolBridge(target: GatewayTarget, profile: stri
     const value = JSON.parse(response.body.toString())
     ready = response.status === 200 && value.version === 1 && value.ready === true && value.in_process === true && value.native_tools === true
   } catch { /* Missing or incompatible plugins do not break ordinary Bot conversations. */ }
-  if (!ready) throw new HttpError(409, '该基础 Agent 尚未启用新版 Hermes 工具桥(yaoyao-bot-bridge)。请在对应 Profile 安装或修复并重新加载后,再开启组队权限。', 'team_tools_unavailable')
+  if (!ready) throw new HttpError(409, '该基础机器人尚未启用新版 Hermes 工具桥(yaoyao-bot-bridge)。请在对应 Profile 安装或修复并重新加载后,再开启组队权限。', 'team_tools_unavailable')
 }
 
 export interface WorkspaceToolLease {
@@ -128,6 +128,7 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
         assertActive()
         try {
           const value = await input.call(tool.id, call.arguments, call.callId)
+          if(value&&typeof value==='object'&&Array.isArray((value as any).content))return value
           return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value }
         } catch (error) {
           return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof HttpError ? error.message : '团队操作失败,请检查当前状态后再重试。', code: error instanceof HttpError ? error.code : 'team_tool_failed' }) }], isError: true }

+ 1 - 1
src/shared/computerControl.ts

@@ -1,5 +1,5 @@
 export interface ComputerFrame {id:string;generation:number;data:string;width:number;height:number;capturedAt:number}
-export interface ComputerControlStatus {mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
+export interface ComputerControlStatus {backend?:'grok';mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
 export type ComputerInput =
   | {kind:'click';x:number;y:number;button?:'left'|'right'|'middle';count?:number}
   | {kind:'drag';fromX:number;fromY:number;toX:number;toY:number}

+ 1 - 0
src/shared/workspace.ts

@@ -6,6 +6,7 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   execution?: 'profile' | 'computer'
+  computer?: 'auto' | 'cloud' | 'vm' | 'off'
   computerEnvironmentId?:string
   computerEnvironmentName?:string
   canManageTeam?: boolean

+ 17 - 0
src/shared/workspacePanels.ts

@@ -0,0 +1,17 @@
+export interface WorkspaceInspectorEntry {
+  id:string; at:number; agentId:string; runId:string; taskId?:string
+  direction:'request'|'response'|'event'|'error'; method:string; requestId?:string; durationMs?:number; data:unknown
+}
+export interface WorkspaceSchedule {
+  kind:'once'|'interval'|'daily'|'weekly'; timezone:string
+  at?:number; everyMinutes?:number; time?:string; weekdays?:number[]
+}
+export interface WorkspaceRoutine {
+  id:string; agentId:string; name:string; prompt:string; enabled:boolean; schedule:WorkspaceSchedule
+  nextAt?:number; lastAt?:number; createdAt:number; updatedAt:number
+}
+export interface WorkspaceRoutineRun {
+  id:string; routineId:string; agentId:string; scheduledAt:number; startedAt:number
+  status:'queued'|'running'|'waiting'|'complete'|'failed'|'interrupted'|'uncertain'|'skipped'
+  conversationId?:string; runId?:string; error?:string
+}

+ 1 - 1
tests/client/agentManagementDialog.test.ts

@@ -30,7 +30,7 @@ describe('Agent management dialog', () => {
   it('keeps non-admin users on identity management only', async () => {
     const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: false } })
     await nextTick()
-    expect(document.querySelector('[aria-label="Agent 管理"]')?.textContent).toContain('自定义角色')
+    expect(document.querySelector('[aria-label="机器人管理"]')?.textContent).toContain('自定义角色')
     expect(document.querySelector('.management-tabs')).toBeNull()
     expect(document.body.textContent).not.toContain('模型服务')
     expect(document.body.textContent).toContain('custom:tingly / omni')

+ 5 - 5
tests/client/groupHost.test.ts

@@ -55,8 +55,8 @@ describe('group host controls', () => {
     await wrapper.get('button[aria-label^="信息收集团队"]').trigger('click')
     expect(wrapper.get<HTMLInputElement>('input[placeholder="例如:产品评审"]').element.value).toBe('信息收集团队')
     expect(wrapper.findAll('.role-mapping select')).toHaveLength(4)
-    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的 Agent"]').setValue('local|yaoyao')
-    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的 Agent"]').setValue('local|reviewer')
+    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的机器人"]').setValue('local|yaoyao')
+    await wrapper.get<HTMLSelectElement>('select[aria-label="信息检索对应的机器人"]').setValue('local|reviewer')
     await wrapper.get('.solid-button').trigger('click')
 
     const payload = wrapper.emitted('create')?.[0]?.[0] as { members: Array<{ profile: string; displayName: string; description: string }>; hostProfile: string; autoReply: boolean; orchestrationMode: string; instructions: string }
@@ -130,7 +130,7 @@ describe('group host controls', () => {
   })
 
   it('adds an Agent from a paired node to an existing group', async () => {
-    const local = agent('agent-local', 'default', '本机 Agent', true, true)
+    const local = agent('agent-local', 'default', '本机机器人', true, true)
     const remote = {
       id: '11111111-1111-4111-8111-111111111111|reviewer',
       profile: 'reviewer',
@@ -146,7 +146,7 @@ describe('group host controls', () => {
       },
     })
 
-    await wrapper.get<HTMLSelectElement>('select[aria-label="选择要添加的 Agent"]').setValue(remote.id)
+    await wrapper.get<HTMLSelectElement>('select[aria-label="选择要添加的机器人"]').setValue(remote.id)
     expect(wrapper.emitted('addAgent')?.at(-1)).toEqual([remote.id])
     wrapper.unmount()
   })
@@ -261,7 +261,7 @@ describe('group host controls', () => {
     await wrapper.setProps({ agents: [{ ...first, isHost: false }, { ...second, isHost: true }] })
     await wrapper.get('button[aria-label="设置瑶儿"]').trigger('click')
     await nextTick()
-    const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿 Agent 设置"]')
+    const dialog = document.querySelector('[role="dialog"][aria-label="瑶儿机器人设置"]')
     expect(dialog?.textContent).toContain('管理员始终处理用户无 @ 消息')
     expect(dialog?.querySelector('input[aria-label="无需 @ 也回复"]')).not.toBeNull()
     wrapper.unmount()

+ 1 - 1
tests/client/kanbanView.test.ts

@@ -81,7 +81,7 @@ describe('Kanban components', () => {
     expect(wrapper.text()).toContain('编辑仅限管理员')
     expect(wrapper.text()).toContain('请补充验收')
     expect(wrapper.text()).toContain('正在执行')
-    expect(wrapper.text()).toContain('Agent 开始处理')
+    expect(wrapper.text()).toContain('机器人开始处理')
     expect(wrapper.findAll('input:disabled, textarea:disabled, select:disabled').length).toBeGreaterThan(0)
     expect(wrapper.find('.kanban-comment-form').exists()).toBe(false)
     expect(wrapper.find('.kanban-task-form__actions').exists()).toBe(false)

+ 4 - 4
tests/client/settingsCenterDialog.test.ts

@@ -5,7 +5,7 @@ import type { Profile } from '@shared/types'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 
 const profiles: Profile[] = [
-  { name: 'ops:blue/team', agentName: '运维 Agent', isDefault: false },
+  { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false },
   { name: 'default', agentName: '丫头', isDefault: true },
 ]
 
@@ -85,7 +85,7 @@ describe('Settings center dialog', () => {
     const wrapper = mountSettings()
 
     expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual([
-      '当前 Agent',
+      '当前机器人',
       '账号',
       '系统 · 仅管理员',
     ])
@@ -94,7 +94,7 @@ describe('Settings center dialog', () => {
     expect(navigationButton(wrapper, '模型与 Provider').attributes('aria-current')).toBe('page')
     const modelPanel = wrapper.get('[data-testid="model-services"]')
     expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
-    expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维 Agent / ops:blue/team')
+    expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维机器人 / ops:blue/team')
 
     await navigationButton(wrapper, '登录与安全').trigger('click')
     expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
@@ -114,7 +114,7 @@ describe('Settings center dialog', () => {
     const wrapper = mountSettings({ isAdmin: false, initialPage: 'system-update' })
     const navigation = wrapper.get('.settings-sidebar nav')
 
-    expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['当前 Agent', '账号'])
+    expect(navigation.findAll('h3').map(heading => heading.text())).toEqual(['当前机器人', '账号'])
     expect(navigation.text()).not.toContain('模型与 Provider')
     expect(navigation.text()).not.toContain('系统 · 仅管理员')
     expect(navigation.text()).not.toContain('系统概览')

+ 6 - 6
tests/client/workspaceShell.test.ts

@@ -7,7 +7,7 @@ import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 
 const profiles: Profile[] = [
   { name: 'default', agentName: '丫头', isDefault: true },
-  { name: 'ops:blue/team', agentName: '运维 Agent', isDefault: false },
+  { name: 'ops:blue/team', agentName: '运维机器人', isDefault: false },
 ]
 
 const SettingsCenterDialogStub = defineComponent({
@@ -89,10 +89,10 @@ describe('Workspace shell account controls', () => {
     expect(agentTrigger.attributes('aria-expanded')).toBe('true')
     const menu = desktop.get('.profile-menu')
     expect(menu.attributes('role')).toBe('listbox')
-    expect(menu.text()).toContain('切换 Agent')
+    expect(menu.text()).toContain('切换机器人')
     expect(menu.text()).toContain('丫头')
-    expect(menu.text()).toContain('运维 Agent')
-    expect(menu.text()).not.toContain('Agent 设置')
+    expect(menu.text()).toContain('运维机器人')
+    expect(menu.text()).not.toContain('机器人设置')
     expect(menu.findAll('button')).toHaveLength(profiles.length)
     expect(menu.findAll('[role="option"]').map(option => option.attributes('aria-selected'))).toEqual(['true', 'false'])
     expect(menu.text()).not.toContain('账号安全')
@@ -103,7 +103,7 @@ describe('Workspace shell account controls', () => {
     expect(document.activeElement).toBe(menu.find('[role="option"][aria-selected="true"]').element)
     await menu.trigger('keydown', { key: 'ArrowDown' })
     const targetProfile = menu.findAll<HTMLButtonElement>('button')
-      .find(button => button.text().includes('运维 Agent'))!
+      .find(button => button.text().includes('运维机器人'))!
     expect(document.activeElement).toBe(targetProfile.element)
     await targetProfile.trigger('click')
     expect(wrapper.emitted('selectProfile')).toEqual([['ops:blue/team']])
@@ -164,7 +164,7 @@ it('uses the reference Bot list header and keeps mode changes in settings', asyn
   await wrapper.get('[data-testid="close-settings"]').trigger('click')
   await rail.get('.sidebar-create-trigger').trigger('click')
   const items = document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')
-  expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '添加远程 Agent', '新建群聊'])
+  expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '添加远程机器人', '新建群聊'])
   items[0]?.click()
   await wrapper.vm.$nextTick()
   expect(wrapper.emitted('createAgent')).toHaveLength(1)

+ 70 - 0
tests/e2e/bot-panels.spec.ts

@@ -0,0 +1,70 @@
+import {test,expect,type Page} from '@playwright/test'
+import {mkdirSync} from 'node:fs'
+const evidence='docs/verification/2026-09-10-bot-panels'
+async function create(page:Page,name:string){
+ await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
+ await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+ const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill(name)
+ await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await expect(page.getByRole('heading',{name,exact:true})).toBeVisible()
+}
+test('robot sidebar, source changes, requests, routines and group boundaries work on desktop and phone',async({page})=>{
+ mkdirSync(evidence,{recursive:true})
+ 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 expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+ await create(page,'侧栏验收')
+ await page.getByRole('button',{name:'聊天设置',exact:true}).click()
+ const editor=page.getByRole('dialog')
+ await editor.getByLabel('基础机器人',{exact:false}).selectOption(JSON.stringify(['local','server']))
+ await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await expect(editor).not.toBeVisible()
+ const agents=(await(await page.request.get('/api/app/agents')).json()).agents
+ const first=agents.find((a:any)=>a.name==='侧栏验收');expect(first.profile).toBe('server')
+ const composer=page.getByRole('textbox',{name:'输入消息,Enter 发送,Shift + Enter 换行'})
+ await composer.fill('侧栏真实请求验收 '+Date.now())
+ await page.getByRole('button',{name:'发送消息',exact:true}).click()
+ await expect(page.locator('.message--assistant')).toContainText('会话独立保存')
+ await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
+ const panel=page.getByRole('complementary',{name:'机器人右侧栏'})
+ await expect(panel.getByRole('tab',{name:'自动',exact:true})).toBeVisible()
+ await expect(panel.getByRole('tab',{name:'云端 · Grok Bot',exact:true})).toBeVisible()
+ await page.screenshot({path:evidence+'/desktop-computer.png'})
+ await panel.getByRole('button',{name:'Inspector',exact:true}).click()
+ await panel.getByRole('button',{name:'Raw',exact:true}).click()
+ await expect(panel.locator('summary').filter({hasText:'prompt.submit'}).first()).toBeVisible()
+ await page.screenshot({path:evidence+'/desktop-inspector.png'})
+ await panel.getByRole('button',{name:'定时任务',exact:true}).click()
+ await panel.getByRole('button',{name:'新建定时任务',exact:true}).click()
+ const routineEditor=page.getByRole('dialog',{name:'新建定时任务',exact:true})
+ await routineEditor.getByRole('textbox',{name:'名称',exact:true}).fill('每小时核对')
+ await routineEditor.getByRole('textbox',{name:'任务内容',exact:true}).fill('核对项目状态并给出结果')
+ await routineEditor.getByLabel('重复',{exact:true}).selectOption('interval')
+ await routineEditor.getByRole('button',{name:'保存',exact:true}).click()
+ await expect(panel.getByRole('heading',{name:'每小时核对',exact:true})).toBeVisible()
+ await panel.getByRole('heading',{name:'每小时核对',exact:true}).click()
+ await page.getByRole('dialog',{name:'编辑定时任务',exact:true}).getByRole('button',{name:'立即执行',exact:true}).click()
+ await expect(panel.locator('.run-state')).toContainText('已完成',{timeout:15000})
+ await page.screenshot({path:evidence+'/desktop-routine-logs.png'})
+ await panel.getByRole('button',{name:'返回定时任务列表',exact:true}).click()
+ await page.screenshot({path:evidence+'/desktop-routines.png'})
+ await panel.getByRole('button',{name:'关闭机器人侧栏',exact:true}).click()
+ await create(page,'群聊成员')
+ const all=(await(await page.request.get('/api/app/agents')).json()).agents
+ const second=all.find((a:any)=>a.name==='群聊成员')
+ const capabilities=await(await page.request.get('/api/realtime/capabilities')).json()
+ const response=await page.request.post('/api/app/conversations',{headers:{'X-CSRF-Token':capabilities.csrfToken,Origin:'http://127.0.0.1:18832'},data:{name:'群聊侧栏验收',memberIds:[first.id,second.id],administratorId:first.id}})
+ expect(response.ok(),await response.text()).toBe(true)
+ const group=(await response.json()).conversation
+ await page.goto('/conversations/'+group.id)
+ await expect(page.getByRole('button',{name:'电脑与定时任务',exact:true})).toHaveCount(0)
+ await page.getByRole('button',{name:'Inspector',exact:true}).click()
+ await expect(panel.getByRole('button',{name:'电脑',exact:true})).toHaveCount(0)
+ await expect(panel.getByRole('button',{name:'定时任务',exact:true})).toHaveCount(0)
+ await page.setViewportSize({width:375,height:812});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+ await page.screenshot({path:evidence+'/phone-group-inspector.png'})
+ const box=await panel.boundingBox();expect(box!.width).toBeLessThanOrEqual(375)
+ await expect(panel.getByRole('button',{name:'关闭机器人侧栏',exact:true})).toBeVisible()
+})

+ 7 - 7
tests/e2e/workspace-chat.spec.ts

@@ -174,7 +174,7 @@ test('created roles and editable teams share a durable chat list without plugin
   const plus = rail.getByRole('button', { name: '新建', exact: true })
   await expect(rail.locator('.sidebar-collapse')).toHaveCount(0)
   await plus.click()
-  await expect(page.getByRole('menu', { name: '新建聊天' }).getByRole('menuitem')).toHaveText(['新建 Bot', '添加远程 Agent', '新建群聊'])
+  await expect(page.getByRole('menu', { name: '新建聊天' }).getByRole('menuitem')).toHaveText(['新建 Bot', '添加远程机器人', '新建群聊'])
   await page.keyboard.press('Escape')
   await rail.getByRole('button', { name: '搜索', exact: true }).click()
   await expect(page.getByRole('dialog', { name: '搜索聊天' })).toBeVisible()
@@ -375,8 +375,8 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.screenshot({ path: testInfo.outputPath('restored-team-presets.png'), fullPage: true })
   await dialog.getByRole('button', { name: '产品设计团队', exact: true }).click()
   await expect(dialog.getByRole('textbox', { name: '名称', exact: true })).toHaveValue('产品设计团队')
-  await dialog.getByRole('combobox', { name: '产品负责人对应的 Agent', exact: true }).selectOption({ label: '产品经理' })
-  await dialog.getByRole('combobox', { name: '交互设计对应的 Agent', exact: true }).selectOption({ label: '开发工程师' })
+  await dialog.getByRole('combobox', { name: '产品负责人对应的机器人', exact: true }).selectOption({ label: '产品经理' })
+  await dialog.getByRole('combobox', { name: '交互设计对应的机器人', exact: true }).selectOption({ label: '开发工程师' })
   const [submitted] = await Promise.all([
     page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/api/app/conversations')),
     dialog.getByRole('button', { name: '保存', exact: true }).click(),
@@ -443,7 +443,7 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   const username = `child-${Date.now()}`
   await settings.locator('input[name=managed-username]').fill(username)
   await settings.locator('input[name=managed-temporary-password]').fill('temporary-pass')
-  await settings.getByRole('listbox', { name: '新子账号的基础 Agent' }).selectOption('default')
+  await settings.getByRole('listbox', { name: '新子账号的基础机器人' }).selectOption('default')
   await settings.getByRole('button', { name: '创建用户', exact: true }).click()
   await expect(settings.getByRole('listbox', { name: `分配给 ${username} 的基础 Agent`, exact: true })).toHaveValues(['default'])
   await page.screenshot({ path: testInfo.outputPath('admin-agent-allocation.png'), fullPage: true })
@@ -465,9 +465,9 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   await child.keyboard.press('Escape')
   await openCreate(child, '新建 Bot')
   const dialog = child.getByRole('dialog')
-  await expect(dialog.getByLabel('基础 Agent').locator('option')).toHaveCount(1)
-  await expect(dialog.getByLabel('基础 Agent').locator('option')).toContainText('通用助手')
-  await dialog.getByLabel('基础 Agent').scrollIntoViewIfNeeded()
+  await expect(dialog.getByLabel('基础机器人').locator('option')).toHaveCount(1)
+  await expect(dialog.getByLabel('基础机器人').locator('option')).toContainText('通用助手')
+  await dialog.getByLabel('基础机器人').scrollIntoViewIfNeeded()
   await child.screenshot({ path: testInfo.outputPath('subaccount-assigned-source.png'), fullPage: true })
   await dialog.getByRole('textbox', { name: '名称', exact: true }).fill('子账号助手')
   await dialog.getByRole('button', { name: '保存', exact: true }).click()

+ 6 - 2
tests/server/sharedComputers.test.ts

@@ -36,12 +36,16 @@ it('requires an explicit human grant, one compatible source and idle permanent m
     expect(store.require<any>('owner','shared-computer',shared.id).archived).toBe(true)
   }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
 })
-it('does not grant temporary helpers or mixed profiles a shared desktop',()=>{
+it('shares one desktop across authorized profiles and rejects temporary helpers',()=>{
   const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{sharedComputerRunner:()=>({id:'runner'})} as any)
   try{
     const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'}),second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'different'})
     const command={requestId:randomUUID(),name:'共享',memberIds:[first.id,second.id],trusted:true}
-    expect(()=>service.create('owner',command)).toThrow('同一来源')
+    const group=service.create('owner',command)
+    expect(store.require<WorkspaceAgent>('owner','agent',second.id).computerEnvironmentId).toBe(group.id)
+    nodes.requireSource('owner',store.require<WorkspaceAgent>('owner','agent',second.id))
+    service.remove('owner',group.id)
+    command.requestId=randomUUID()
     store.put('owner','agent',second.id,{...second,profile:'default',temporaryGoalId:randomUUID()})
     expect(()=>service.create('owner',command)).toThrow('持久隔离成员')
   }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}

+ 134 - 0
tests/server/workspacePanels.test.ts

@@ -0,0 +1,134 @@
+// @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 {randomUUID} from 'node:crypto'
+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 {WorkspaceRoutines,nextRoutineAt} from '../../src/server/workspaceRoutines'
+import {WorkspaceInspector,redactInspector} from '../../src/server/workspaceInspector'
+import {GrokCloud} from '../../src/server/grokCloud'
+
+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(),'workspace-panels-'));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});vi.useRealTimers()})
+
+it('changes the foundation only when idle and retires old session bindings without changing history',()=>{
+ const agent=store.createAgent('owner',{name:'甲',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
+ store.put('owner','binding',`${conversation.id}:${agent.id}`,{id:`${conversation.id}:${agent.id}`,storedId:'old-session'})
+ store.put('owner','turn','busy',{agentId:agent.id,status:'queued'})
+ expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrow('先停止')
+ store.remove('owner','turn','busy')
+ expect(store.updateAgent('owner',agent.id,{profile:'other'}).profile).toBe('other')
+ expect(store.list('owner','binding')).toEqual([])
+ expect(store.require('owner','conversation',conversation.id)).toMatchObject({id:conversation.id,name:'甲'})
+})
+
+it('calculates daily and weekly schedules in the requested timezone, including DST',()=>{
+ const after=Date.parse('2026-09-10T00:00:00Z')
+ expect(nextRoutineAt({kind:'daily',timezone:'Asia/Shanghai',time:'09:00'},after)).toBe(Date.parse('2026-09-10T01:00:00Z'))
+ expect(nextRoutineAt({kind:'weekly',timezone:'Asia/Shanghai',time:'09:00',weekdays:[1]},after)).toBe(Date.parse('2026-09-14T01:00:00Z'))
+ expect(nextRoutineAt({kind:'daily',timezone:'America/New_York',time:'09:00'},Date.parse('2026-03-07T15:00:00Z'))).toBe(Date.parse('2026-03-08T13:00:00Z'))
+})
+
+it('keeps the original next occurrence when only a routine title or prompt changes',()=>{
+ vi.useFakeTimers();vi.setSystemTime(new Date('2026-09-10T00:00:00Z'))
+ const agent=store.createAgent('owner',{name:'排期验收',profile:'default'}),service=new WorkspaceRoutines(store,auth,nodes,{} as any)
+ const body={name:'项目核对',prompt:'核对状态',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:60}}
+ const routine=service.save('owner',agent.id,body)
+ vi.setSystemTime(new Date('2026-09-10T00:25:00Z'))
+ expect(service.save('owner',agent.id,{...body,name:'每日项目核对'},routine.id).nextAt).toBe(routine.nextAt)
+ service.close()
+})
+
+it('persists due schedules, prevents overlap and deduplicates manual runs after restart',()=>{
+ vi.useFakeTimers();vi.setSystemTime(new Date('2026-09-10T00:00:00Z'))
+ const agent=store.createAgent('owner',{name:'日报机器人',profile:'default'})
+ const send=vi.fn((_owner:string,_conversation:string,input:any)=>{const run={id:input.requestId,status:'queued'};store.put('owner','run',run.id,run);return run})
+ const routines=new WorkspaceRoutines(store,auth,nodes,{send} as any)
+ const routine=routines.save('owner',agent.id,{name:'检查',prompt:'检查任务',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:1}})
+ vi.setSystemTime(new Date('2026-09-10T00:01:01Z'));routines.tick();routines.tick()
+ expect(send).toHaveBeenCalledTimes(1)
+ const replacement=new WorkspaceRoutines(store,auth,nodes,{send} as any);replacement.tick();expect(send).toHaveBeenCalledTimes(1)
+ vi.setSystemTime(new Date('2026-09-10T00:02:02Z'));replacement.tick()
+ expect(send).toHaveBeenCalledTimes(1);expect(replacement.runs('owner',agent.id).some(run=>run.status==='skipped')).toBe(true)
+ const first=replacement.runs('owner',agent.id).find(run=>run.runId)!
+ store.put('owner','run',first.runId!,{id:first.runId,status:'complete'})
+ const requestId=randomUUID();replacement.run('owner',routine,0,requestId);replacement.run('owner',routine,0,requestId)
+ expect(send).toHaveBeenCalledTimes(2)
+ expect(()=>replacement.save('other',agent.id,{name:'forbidden'})).toThrow()
+ routines.close();replacement.close()
+})
+
+it('records redacted requests and enforces conversation ownership and task scope',async()=>{
+ const agent=store.createAgent('owner',{name:'检查机器人',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
+ const inspector=new WorkspaceInspector(store,auth)
+ inspector.record('owner',conversation.id,{agentId:agent.id,runId:randomUUID(),method:'prompt.submit',direction:'request',data:{authorization:'Bearer secret',nested:{access_token:'secret'},prompt:'hello',content_base64:'screenshot'}})
+ const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(inspector.router().routes())
+ const own=await request(app.callback()).get(`/api/app/conversations/${conversation.id}/inspector`)
+ expect(own.status).toBe(200);expect(own.body.entries[0].data).toMatchObject({authorization:'[已隐藏]',prompt:'hello',nested:{access_token:'[已隐藏]'}})
+ expect(JSON.stringify(own.body)).not.toContain('secret')
+ expect((await request(app.callback()).get(`/api/app/conversations/${conversation.id}/inspector`).set('x-user','other')).status).toBe(404)
+ expect(redactInspector({prompt:'Bearer abc-secret'})).toEqual({prompt:'Bearer [已隐藏]'})
+})
+
+function fakeCloud(){
+ let ensure=0,exec=0
+ const fetcher=vi.fn(async(url:any,init:any)=>{
+  const path=new URL(String(url)).pathname
+  if(path.endsWith('GetSandBoxRunState'))return Response.json({state:'SAND_BOX_RUN_STATE_RUNNING'})
+  if(path.endsWith('EnsureSandBox')){ensure++;return Response.json({gatewayUrl:'https://gateway.cursorvm.com',gatewayToken:'private-gateway',networkToken:'private-network',execDaemonUrl:'https://exec.cursorvm.com',execDaemonAuthToken:'private-exec'})}
+  if(path==='/health')return Response.json({ok:true,isBusy:false})
+  if(path.endsWith('/Exec')){exec++;const frames=[{stdoutEvent:{data:'remote result'}},{exitEvent:{}},{}].map((event,i)=>{const text=Buffer.from(JSON.stringify(event)),frame=Buffer.alloc(text.length+5);frame[0]=i===2?2:0;frame.writeUInt32BE(text.length,1);text.copy(frame,5);return frame});return new Response(Buffer.concat(frames),{headers:{'content-type':'application/connect+json'}})}
+  throw new Error('Unexpected request '+path)
+ }) as typeof fetch
+ const cloud=new GrokCloud(store,auth,nodes,{assertLocalVmIdle:()=>{}} as any,fetcher)
+ const token='header.'+Buffer.from(JSON.stringify({sub:'fixture',exp:Math.floor(Date.now()/1000)+3600})).toString('base64url')+'.signature'
+ return {cloud,token,fetcher,counts:()=>({ensure,exec})}
+}
+
+it('auto observation never provisions; multiple robots share one cloud connection with private credentials',async()=>{
+ const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
+ await cloud.state('owner');await cloud.status('owner');expect(counts()).toEqual({ensure:0,exec:0})
+ const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'different',computer:'cloud'})
+ await Promise.all([cloud.call('owner',a.id,'cloud_computer_shell',{command:'pwd'}),cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})])
+ expect(counts()).toEqual({ensure:1,exec:2})
+ expect(JSON.stringify(store.list('owner','grok-cloud'))).not.toContain(token)
+ await expect(cloud.call('other',a.id,'cloud_computer_shell',{command:'pwd'})).rejects.toThrow()
+ await expect(cloud.configure('other',token,'0.47.0')).rejects.toThrow('其他用户')
+})
+
+it('allows only one human controller for a shared cloud computer and resumes queued robot tools on return',async()=>{
+ const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
+ const a=store.createAgent('owner',{name:'甲',profile:'default',computer:'cloud'}),b=store.createAgent('owner',{name:'乙',profile:'default',computer:'cloud'})
+ const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
+ const take=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/take`).send({requestId:randomUUID()})
+ expect(take.status).toBe(200);expect(take.body.mode).toBe('human')
+ const denied=await request(app.callback()).post(`/api/app/agents/${b.id}/computer/take`).send({requestId:randomUUID()});expect(denied.status).toBe(409)
+ const work=cloud.call('owner',b.id,'cloud_computer_shell',{command:'pwd'})
+ await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
+ const returned=await request(app.callback()).post(`/api/app/agents/${a.id}/computer/giveback`).send({controlId:take.body.controlId,token:take.body.token,notes:'完成登录'})
+ expect(returned.status).toBe(200);await work;expect(counts().exec).toBe(1)
+ expect(store.list('_system','computer-control')).toEqual([])
+})
+
+it('keeps cloud tools paused after a lost human lease until a new explicit handback',async()=>{
+ const {cloud,token,counts}=fakeCloud();await cloud.configure('owner',token,'0.47.0')
+ const agent=store.createAgent('owner',{name:'交还验收',profile:'default',computer:'cloud'})
+ const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message}}});app.use(bodyParser());app.use(cloud.router().routes())
+ const base=`/api/app/agents/${agent.id}/computer`
+ await request(app.callback()).post(base+'/take').send({requestId:randomUUID()})
+ ;(cloud as any).manual.get('owner').expiresAt=Date.now()-1
+ expect(await cloud.status('owner')).toMatchObject({mode:'error'})
+ const work=cloud.call('owner',agent.id,'cloud_computer_shell',{command:'pwd'})
+ await new Promise(resolve=>setTimeout(resolve,30));expect(counts().exec).toBe(0)
+ const renewed=await request(app.callback()).post(base+'/take').send({requestId:randomUUID()});expect(renewed.status).toBe(200)
+ await request(app.callback()).post(base+'/giveback').send({controlId:renewed.body.controlId,token:renewed.body.token,notes:'已完成登录'})
+ expect(await work).toMatchObject({handoffNote:'已完成登录'});expect(counts().exec).toBe(1)
+})

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.