Przeglądaj źródła

按 OpenMausBot 重构本地虚拟机与聊天电脑面板并移除旧镜像设计

LSamien 1 miesiąc temu
rodzic
commit
d81a928b5b
59 zmienionych plików z 1160 dodań i 734 usunięć
  1. 3 0
      desktop/main.mjs
  2. 1 1
      docs/agent-platform-design.md
  3. 1 1
      docs/agent-platform-implementation.md
  4. 1 1
      docs/computer-maintenance.md
  5. 0 60
      docs/computer-management-ui.md
  6. 40 0
      docs/local-vm.md
  7. BIN
      docs/screenshots/local-vm/agent-panel.png
  8. BIN
      docs/screenshots/local-vm/interactive-desktop.png
  9. BIN
      docs/screenshots/local-vm/phone-chat-entry.png
  10. BIN
      docs/screenshots/local-vm/phone-desktop.png
  11. BIN
      docs/screenshots/local-vm/settings.png
  12. BIN
      docs/screenshots/local-vm/two-desktops.png
  13. BIN
      docs/screenshots/openmaus-layout/android-desktop.png
  14. BIN
      docs/screenshots/openmaus-layout/bot-header-computer-entry.png
  15. BIN
      docs/screenshots/openmaus-layout/computer-immersive-control.png
  16. BIN
      docs/screenshots/openmaus-layout/computer-readonly.png
  17. BIN
      docs/screenshots/openmaus-layout/image-overview.png
  18. BIN
      docs/screenshots/openmaus-layout/mobile-immersive-large-dark.png
  19. BIN
      docs/screenshots/openmaus-layout/mobile-shared-screen.png
  20. 1 1
      docs/shared-computers.md
  21. 1 1
      scripts/build-desktop.mjs
  22. 65 0
      src/client/components/app/LocalVmSettingsPanel.vue
  23. 2 2
      src/client/components/app/RunnerSettingsPanel.vue
  24. 5 0
      src/client/components/app/SettingsCenterDialog.vue
  25. 5 0
      src/client/components/app/WorkspaceShell.vue
  26. 0 44
      src/client/components/workspace/ComputerEnvironmentHub.vue
  27. 61 46
      src/client/components/workspace/ComputerPanel.vue
  28. 0 63
      src/client/components/workspace/ImageManagementPanel.vue
  29. 68 0
      src/client/components/workspace/LocalVmChatPanel.vue
  30. 32 0
      src/client/components/workspace/LocalVmWorkspace.vue
  31. 21 19
      src/client/views/ConversationsView.vue
  32. 36 29
      src/runner/agent.ts
  33. 0 142
      src/runner/computers/imageManagement.ts
  34. 116 0
      src/runner/computers/localVm.ts
  35. 37 6
      src/runner/computers/pool.ts
  36. 16 6
      src/runner/worker/control.ts
  37. 4 3
      src/runner/worker/gateway.ts
  38. 8 4
      src/server/app.ts
  39. 2 2
      src/server/computerControls.ts
  40. 0 37
      src/server/imageManagement.ts
  41. 170 0
      src/server/localVm.ts
  42. 24 24
      src/server/runnerHub.ts
  43. 37 1
      src/server/sharedComputers.ts
  44. 2 0
      src/server/workspaceStore.ts
  45. 2 1
      src/shared/computerControl.ts
  46. 0 3
      src/shared/imageManagement.ts
  47. 24 0
      src/shared/localVm.ts
  48. 1 1
      src/shared/runner.ts
  49. 86 96
      tests/e2e/team-tools.spec.ts
  50. 15 21
      tests/fixtures/workspace-server.ts
  51. 117 0
      tests/live/localVm.live.test.ts
  52. 9 30
      tests/live/worker.live.test.ts
  53. 6 0
      tests/server/computerControl.test.ts
  54. 5 5
      tests/server/desktopSync.test.ts
  55. 0 48
      tests/server/imageManagement.test.ts
  56. 0 24
      tests/server/imageManagementRoutes.test.ts
  57. 88 0
      tests/server/localVm.test.ts
  58. 48 0
      tests/server/localVmRoutes.test.ts
  59. 0 12
      tests/server/runner.test.ts

+ 3 - 0
desktop/main.mjs

@@ -90,6 +90,8 @@ else {
   }
   }
   function stateChanged(state) {
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
     log(`${state.phase}: ${state.message}`)
+    const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
+    if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
     const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
     const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
     if (notice) notice.visible = Boolean(state.updateNotice)
     if (notice) notice.visible = Boolean(state.updateNotice)
     const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
     const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
@@ -158,6 +160,7 @@ else {
     Menu.setApplicationMenu(Menu.buildFromTemplate([
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
+        { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
         { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
         { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
         { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
         { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,

+ 1 - 1
docs/agent-platform-design.md

@@ -88,4 +88,4 @@ Runner 默认最多同时运行 2 台电脑,可配置 1–8 台;默认最多
 配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
 配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
 
 
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
docs/agent-platform-implementation.md

@@ -117,4 +117,4 @@ Docker 已做真实运行验收;Podman 有接口与离线一致性测试,未
 模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
 模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
 
 
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
docs/computer-maintenance.md

@@ -47,4 +47,4 @@ CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
 
 
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 0 - 60
docs/computer-management-ui.md

@@ -1,60 +0,0 @@
-# 电脑、镜像与屏幕的界面入口
-
-## 平台边界
-
-- Web/macOS:Bot 模式聊天列表中常驻“电脑与环境”,包含电脑、共享环境、镜像、节点配置。
-- iOS/Android:常驻“桌面与屏幕”,只列出虚拟桌面和共享屏幕,支持查看、接管、输入和交还。没有镜像管理、共享成员变更或执行环境切换入口。
-- 没有电脑、节点未连接、账号无管理权限或服务端版本较旧时,保留入口并显示原因。手机安装新版客户端不会自动更新它连接的服务端。
-
-## Web/macOS 操作
-
-1. 管理员在“节点配置”注册并连接 Runner,勾选隔离电脑 Worker。镜像 ID 可留空,之后在镜像页准备。
-2. 在“镜像”选择执行节点,点击“准备兼容镜像”,或选择 `.tar` 和配套 `.tar.json` 上传导入。界面显示传输进度及节点上的操作记录。
-3. 准备或导入完成后,在镜像列表选择“应用”。界面显示当前镜像及可回退版本。更新和回退保留工作区。
-4. “导出”会生成可分别下载的镜像与校验清单;导出副本可以清理,原镜像保留。未完成上传可以取消清理。
-5. “电脑”列出所有自己的 Agent 及配置状态,可以启用隔离电脑、进入查看/接管面板。共享环境在独立的“共享环境”页创建、查看成员或解除。
-
-镜像管理仅对服务器管理员开放。镜像任务开始前要求节点的电脑资源空闲,期间新电脑任务等待;未确认测试电脑已停止时保留维护锁,刷新状态可重试清理。后台任务不会因关闭页面自动重复执行,Runner 重启后原未完成任务显示待核对。
-
-## 接口与数据
-
-- `GET /api/app/computers`:当前账号的电脑列表、可用性与原因。
-- `/api/app/computers/shared`:沿用已有账号范围的共享授权。
-- `/api/app/admin/runners/:id/images`:镜像目录、当前/上一个镜像、操作记录。
-- `/images/jobs`:准备、校验、应用、回退、导入、导出;请求编号持久去重。
-- `/images/uploads`:512 KiB 分块传输,上限 20 GiB,校验归档 SHA-256 后导入;不接受宿主路径。
-- `/images/jobs/:id/archive`、`manifest`:下载导出结果;文件在 Runner 侧,控制面流式转发。
-
-Runner 使用 `image-management-v1` 能力协商,镜像选择与任务记录保存在 Runner 的 SQLite 中。应用的镜像覆盖节点配置中的默认镜像,重新连接后保留;重新导入不同默认镜像配置时清除旧覆盖。管理请求经过管理员身份和授权版本检查,模型工具没有这些管理接口或凭据。
-
-## 验证
-
-Web 流程覆盖常驻入口、未配置提示、应用/回退、导出下载及旧服务提示,并检查窄屏深色截图。手机 UI 流程确认没有管理按钮,分别显示独立/共享屏幕并完成接管交还。已有 iOS 28 项 Workspace 测试通过,Android 编译与原生 UI 流程通过。
-
-真实 Docker + 打包 Runner + 控制面已完成镜像准备、校验、应用、回退,并导出、分块下载/上传及重新导入约 500 MB 的兼容镜像,核对 SHA-256。镜像完成后继续执行原有 Worker、共享电脑和临时助手验收。
-
-本分支最终单元/集成测试为 775 项通过、6 项按环境跳过;浏览器流程 7 项通过,真实 Worker 流程 2 项通过。下载块只保存命令指纹,每次重试重新鉴权读取,避免在回执数据库中重复存储整个归档;上传块在网络中断或服务器暂时错误时最多重试两次。
-
-手机端源码为 iOS 1.3(189),已签名并安装至 iPhone 13 Pro Max;安装后设备离线,启动核验尚未完成。Android 已通过编译和模拟器界面验证。新版客户端连接旧服务时显示升级提示;本次未替换正式运行中的 Web 服务。
-
-
-## OpenMausBot 展示方式对齐
-
-这轮以 `/Users/samien/git/OpenMausBot` 当前源码为参考,重点是 Bot 上下文、镜像状态和手机画面的摆放。
-
-| 参考实现 | 本分支对应行为 |
-| --- | --- |
-| `ios/App/ChatView.swift` 的右上角电脑入口 | iOS/Android 聊天右上角显示电脑图标。一个桌面直接全屏打开,多个桌面先选择;共享成员指向同一环境时只显示一次。 |
-| `ios/App/ComputerView.swift`、Android `ComputerScreen.kt` | 黑色全屏背景,画面保持原比例并居中,顶部显示 Bot 名称、共享环境和预览/控制状态。 |
-| 手机端主画面底部的桌面操作 | 底部保留接管/交还主按钮;接管后点“键盘与操作”打开面板,提供文字、按键、左右键、滚动和交还说明。 |
-| 按查看生命周期订阅屏幕 | 当前传输沿用已授权的 Runner 帧接口,仅查看时刷新,退出或后台停止轮询;连接失败显示画面已暂停并禁止输入旧帧。 |
-| `src/components/LocalComputerSection.tsx` 的镜像就绪状态 | Web/macOS 镜像页突出当前应用状态,展示“准备镜像 → 应用到节点 → 打开 Bot 电脑”的顺序。 |
-
-聊天列表的“桌面与屏幕”改为顶部图标,不占用聊天内容和输入区。桌面列表使用紧凑的图标行;没有桌面时才展示配置指引。手机依然不管理镜像、共享成员或执行环境。
-
-本轮原生界面使用从兼容镜像中实际截取的 1280×900 XFCE 桌面进行展示验收;交互 API 由隔离测试服务提供。iOS 覆盖聊天右上角入口、独立/共享选择、全屏预览、接管、键盘输入、交还、大字体深色布局。Android 另外验证等比留黑区域不会产生点击,桌面中心正确映射到远端坐标。Web 7 项浏览器流程通过。真实 Runner 的端到端验收记录见上节;本轮未更换屏幕传输协议。
-
-新版手机界面构建号为 iOS 1.3(190)。189 的既有安装记录不代表本轮布局已经覆盖安装,交付时需再次核对设备状态。
-
-
-本轮截图:[聊天右上角入口](screenshots/openmaus-layout/bot-header-computer-entry.png)、[iOS 全屏画面](screenshots/openmaus-layout/computer-readonly.png)、[接管后的操作栏](screenshots/openmaus-layout/computer-immersive-control.png)、[Android 全屏画面](screenshots/openmaus-layout/android-desktop.png)、[镜像状态](screenshots/openmaus-layout/image-overview.png)。

+ 40 - 0
docs/local-vm.md

@@ -0,0 +1,40 @@
+# 本地虚拟机
+
+本实现参照 OpenMausBot 的 `LocalComputerSection.tsx`、`ComputerPanel.tsx`、`LocalVmWorkspace.tsx` 及手机 `ComputerView.swift` / `ComputerScreen.kt`。
+
+## 使用流程
+
+1. 进入应用设置 → **本地虚拟机**。macOS App 的应用菜单也提供“本地虚拟机设置…”。
+2. 检查 Docker/Podman 已安装并启动,点击 **准备本地虚拟机**。本机执行环境自动连接,不需要手工填写镜像 ID、下载 Runner 配置再导入。准备会复用兼容镜像或构建固定版本,并验证实际桌面后自动使用。
+3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一基础 Profile 的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
+4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,从这里创建或启动该 Agent 的桌面。
+5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
+6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
+
+虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
+
+## 手机端
+
+iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择。入口在 **当前 Agent 聊天右上角的电脑图标**。单个 Agent 直接打开画面,群聊有多台电脑时选择当前聊天成员的电脑,同一共享环境只显示一次。
+
+黑底全屏、按原比例显示画面,顶部是 Agent 名称、共享标识和状态,底部是接管/交还操作。键盘、文字、滚动及交还说明单独展开。未启用电脑的 Agent 显示桌面端配置提示,不由手机自动创建环境。
+
+## 范围与接口
+
+- 已删除此前的“电脑与环境”聊天列表入口、独立镜像目录、应用/回退/导入/导出界面及相应管理 HTTP API。
+- 旧工作区、旧共享数据、已下载归档和 Docker 镜像不会因删除界面而被删除。无人使用的保留实例可在本地虚拟机设置中停止或移除,工作文件继续保留。
+- 托管的本机 Runner 凭据只在服务端加密保存,重启 Web 后自动连接;高级外部 Runner 仍可沿用现有连接配置。
+- 本地虚拟机运行在当前连接的服务所在电脑,不跨机器合并容量。
+- `GET /api/app/admin/local-vm` 提供准备状态、隔离策略和实例清单;`POST /prepare` 准备托管镜像,`PUT /policy` 更新策略。
+- `/api/app/agents/:id/local-vm` 提供该 Agent 的配置和状态,`POST /:action` 提供 create/start/stop/recreate/remove。
+- 屏幕与输入沿用受控的 `/api/app/agents/:id/computer` 通道。输入绑定账号、Agent、当前控制令牌、租约版本和截图;旧租约不能继续操作。
+- 应用设置只向管理员开放。共享不会跨账号或 Profile;实例操作会拒绝正在由 Agent 或其他人控制的虚拟机。
+
+## 验收
+
+测试覆盖准备与权限、持久化、实例生命周期、空闲配额、控制权失效、共享范围、聊天右侧面板、双桌面移交及手机聊天入口。另有真实 Docker + Hermes Worker 和从设置自动准备到聊天内操作真实桌面的测试,界面截图来自隔离验收环境。
+
+
+本轮验收结果:787 项单元/集成测试通过;7 项环境限定测试跳过。7 项浏览器流程、iOS 3 项界面流程、Android 2 项界面流程通过。真实 Hermes Worker + Docker 流程 2 项通过;自动准备本机 Runner、真实桌面键盘输入及快捷键、重启保留文件、双桌面控制切换的完整界面流程通过。
+
+截图:[本地虚拟机设置](screenshots/local-vm/settings.png)、[Agent 聊天右侧电脑面板](screenshots/local-vm/agent-panel.png)、[交互桌面](screenshots/local-vm/interactive-desktop.png)、[双桌面工作区](screenshots/local-vm/two-desktops.png)、[手机聊天入口](screenshots/local-vm/phone-chat-entry.png)。

BIN
docs/screenshots/local-vm/agent-panel.png


BIN
docs/screenshots/local-vm/interactive-desktop.png


BIN
docs/screenshots/local-vm/phone-chat-entry.png


BIN
docs/screenshots/local-vm/phone-desktop.png


BIN
docs/screenshots/local-vm/settings.png


BIN
docs/screenshots/local-vm/two-desktops.png


BIN
docs/screenshots/openmaus-layout/android-desktop.png


BIN
docs/screenshots/openmaus-layout/bot-header-computer-entry.png


BIN
docs/screenshots/openmaus-layout/computer-immersive-control.png


BIN
docs/screenshots/openmaus-layout/computer-readonly.png


BIN
docs/screenshots/openmaus-layout/image-overview.png


BIN
docs/screenshots/openmaus-layout/mobile-immersive-large-dark.png


BIN
docs/screenshots/openmaus-layout/mobile-shared-screen.png


+ 1 - 1
docs/shared-computers.md

@@ -33,4 +33,4 @@ python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon
 ```
 ```
 
 
 
 
-2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 1 - 1
scripts/build-desktop.mjs

@@ -31,7 +31,7 @@ await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const service = resolve(out, 'web-service')
 const service = resolve(out, 'web-service')
 await mkdir(service)
 await mkdir(service)
-for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json', 'hermes_worker.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(await realpath(process.execPath), resolve(service, 'node'))
 await cp(await realpath(process.execPath), resolve(service, 'node'))
 await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))
 await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))

+ 65 - 0
src/client/components/app/LocalVmSettingsPanel.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import {ref,onMounted,onBeforeUnmount} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
+import type {WorkspaceAgent} from '@shared/workspace'
+const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
+const instances=ref<Array<{agent:WorkspaceAgent;vm:LocalVmInstance}>>([])
+let closed=false,timer:ReturnType<typeof setTimeout>|undefined
+async function refresh(){
+ if(loading.value||acting.value||closed)return
+ loading.value=true
+ try{
+  const next=await apiRequest<LocalVmStatus>('/api/app/admin/local-vm');if(closed)return;state.value=next
+  const list=await apiRequest<{computers:Array<{agent:WorkspaceAgent}>}>('/api/app/computers')
+  const seen=new Set<string>()
+  const agents=list.computers.map(x=>x.agent).filter(a=>a.execution==='computer'&&!a.remoteAgentId&&a.nodeId==='local'&&!seen.has(a.computerEnvironmentId??a.id)&&!!seen.add(a.computerEnvironmentId??a.id))
+  const rows=await Promise.all(agents.map(async agent=>({agent,vm:await apiRequest<LocalVmInstance>(`/api/app/agents/${agent.id}/local-vm`)})))
+  if(!closed)instances.value=rows
+ }catch(e){if(!closed)error.value=e instanceof Error?e.message:'无法读取本地虚拟机状态'}finally{loading.value=false}
+}
+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
+ void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
+}
+function manage(agent:WorkspaceAgent,operation:LocalVmAction){
+ if(operation==='remove'&&!confirm(`移除“${agent.name}”的虚拟机实例?工作文件和浏览器资料将保留。`))return
+ void action(()=>apiRequest(`/api/app/agents/${agent.id}/local-vm/${operation}`,{method:'POST',body:{}}))
+}
+async function cycle(){await refresh();if(!closed)timer=setTimeout(cycle,state.value?.busy?2000:10000)}
+onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
+</script>
+<template>
+ <section class="local-vm-settings" aria-label="本地虚拟机设置">
+  <article><h3>本地虚拟机</h3><p>在这台电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</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><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
+   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
+   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||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><h3>准备运行环境</h3><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>
+  </ol></article>
+  <article><h3>{{state?.mode==='shared'?'共享虚拟机':'Agent 虚拟机'}}</h3><p v-if="!instances.length">还没有 Agent 使用本地虚拟机。</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>
+  </article>
+ </section>
+</template>
+<style scoped>
+.local-vm-settings{display:grid;gap:16px;max-width:820px}article{padding:20px;border:1px solid var(--line);border-radius:14px;display:grid;gap:12px;background:var(--surface)}h3,p{margin:0}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.status{display:inline-flex;align-items:center;gap:7px;font-size:13px}.segmented{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}.segmented button{flex:1;border:0;border-radius:0}.segmented button[aria-pressed=true]{background:var(--surface-soft);font-weight:600}.instance{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:12px;border-top:1px solid var(--line)}button,select{font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;min-height:40px;padding:7px 12px}button{display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}button:disabled{opacity:.45;cursor:default}.primary{background:var(--accent);color:var(--text-on-solid);margin-top:8px}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}ol{list-style:none;margin:0;padding:0;display:grid;gap:20px}li{display:flex;gap:12px}.step{display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;height:26px;width:26px;flex:none;font-size:12px}li strong{font-size:13px}[role=alert]{color:var(--danger)}
+</style>

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

@@ -78,11 +78,11 @@ onMounted(()=>{void action(refresh)})
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
-        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可稍后在镜像页面准备和应用"></label>
+        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
-        <small>在 Agent 设置中选择“隔离电脑”后使用。当前使用 CUA 0.20.0 layer 5 兼容镜像,注册并连接后,可在“电脑与环境 → 镜像”中准备、导入和应用镜像。</small>
+        <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从 Agent 聊天的电脑面板启用。</small>
       </fieldset>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>
     </form>

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

@@ -10,6 +10,7 @@ import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+import LocalVmSettingsPanel from '@/components/app/LocalVmSettingsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
@@ -28,6 +29,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-push'
   | 'system-nodes'
   | 'system-nodes'
   | 'system-voice'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
   | 'system-update'
 
 
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
@@ -104,6 +106,7 @@ const accountItems = computed<NavigationItem[]>(() => [
   { key: 'appearance', label: '外观', icon: 'sun' },
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
 ])
 const systemItems: NavigationItem[] = [
 const systemItems: NavigationItem[] = [
+  { key: 'system-local-vm', label: '本地虚拟机', icon: 'panel' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
@@ -126,6 +129,7 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   'account-mobile': '手机登录',
   appearance: '外观',
   appearance: '外观',
+  'system-local-vm': '本地虚拟机',
   'system-overview': '系统概览',
   'system-overview': '系统概览',
   'system-users': '用户与权限',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
   'system-connection': 'Hermes 连接',
@@ -356,6 +360,7 @@ function requestModeSwitch() {
                   </div>
                   </div>
                 </section>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
+                <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />

+ 5 - 0
src/client/components/app/WorkspaceShell.vue

@@ -30,6 +30,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-push'
   | 'system-nodes'
   | 'system-nodes'
   | 'system-voice'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
   | 'system-update'
 
 
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
@@ -352,17 +353,21 @@ watch(() => activeNav.value.key, () => {
   profileMenuOpen.value = false
   profileMenuOpen.value = false
 })
 })
 
 
+function showLocalVmSettings(){if(props.isAdmin)openSettings('system-local-vm')}
 onMounted(() => {
 onMounted(() => {
+  window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
   document.addEventListener('mousedown', closeMenus)
   document.addEventListener('mousedown', closeMenus)
   document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.addEventListener('keydown', workspaceKeydown)
   document.addEventListener('keydown', workspaceKeydown)
 })
 })
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
+  window.removeEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   document.removeEventListener('mousedown', closeMenus)
   document.removeEventListener('mousedown', closeMenus)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener('keydown', workspaceKeydown)
   document.removeEventListener('keydown', workspaceKeydown)
 })
 })
+defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}})
 </script>
 </script>
 
 
 <template>
 <template>

+ 0 - 44
src/client/components/workspace/ComputerEnvironmentHub.vue

@@ -1,44 +0,0 @@
-<script setup lang="ts">
-import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
-import {apiRequest} from '@/api/client'
-import {createUuid} from '@/utils/id'
-import type {WorkspaceAgent} from '@shared/workspace'
-import ComputerPanel from './ComputerPanel.vue'
-import ImageManagementPanel from './ImageManagementPanel.vue'
-import RunnerSettingsPanel from '@/components/app/RunnerSettingsPanel.vue'
-const emit=defineEmits<{close:[];changed:[]}>(),dialog=ref<HTMLDialogElement>(),tab=ref('computers'),loading=ref(false),busy=ref(false),error=ref(''),unsupported=ref(false),canManageImages=ref(false)
-const computers=ref<Array<{agent:WorkspaceAgent;available:boolean;reason:string}>>([]),shared=ref<Array<{id:string;name:string;memberIds:string[]}>>([]),viewing=ref<WorkspaceAgent>(),name=ref('团队共享电脑'),members=ref<string[]>([]),trusted=ref(false)
-let closed=false
-const candidates=computed(()=>computers.value.filter(row=>row.agent.execution==='computer'&&!row.agent.temporaryGoalId&&!row.agent.computerEnvironmentId))
-async function refresh(){loading.value=true;error.value='';try{const data=await apiRequest<{computers:typeof computers.value;canManageImages:boolean}>('/api/app/computers');if(closed)return;computers.value=data.computers;canManageImages.value=data.canManageImages;shared.value=(await apiRequest<{computers:typeof shared.value}>('/api/app/computers/shared')).computers;unsupported.value=false}catch(e){unsupported.value=Number((e as any).status)===404;error.value=unsupported.value?'当前连接的服务尚未提供电脑管理,请切换到配套分支服务或更新服务端。':e instanceof Error?e.message:'无法读取电脑环境'}finally{loading.value=false}}
-async function action(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed');await refresh()}catch(e){error.value=e instanceof Error?e.message:'操作失败'}finally{busy.value=false}}
-function enable(agent:WorkspaceAgent){void action(()=>apiRequest(`/api/app/agents/${agent.id}`,{method:'PATCH',body:{execution:'computer'}}))}
-function share(){void action(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:name.value,memberIds:members.value,trusted:trusted.value}});members.value=[];trusted.value=false})}
-onMounted(()=>{dialog.value?.showModal();void refresh()});onBeforeUnmount(()=>{closed=true})
-</script>
-<template>
- <Teleport to="body"><dialog ref="dialog" class="environment-hub" aria-label="电脑与环境" @cancel.prevent="!viewing&&!busy&&emit('close')">
-  <header><div><h2>电脑与环境</h2><p>管理电脑、镜像与共享成员</p></div><button :disabled="busy||!!viewing" @click="emit('close')">关闭</button></header>
-  <nav aria-label="环境管理分类"><button v-for="item in [{id:'computers',name:'电脑'},{id:'shared',name:'共享环境'},{id:'images',name:'镜像'},{id:'nodes',name:'节点配置'}]" :key="item.id" :aria-pressed="tab===item.id" @click="tab=item.id">{{item.name}}</button></nav>
-  <main>
-   <div class="actions"><button :disabled="loading||busy" @click="refresh">刷新状态</button><span v-if="loading" role="status">正在读取…</span></div>
-   <p v-if="error" role="alert">{{error}}</p>
-   <p v-if="unsupported">安装新版手机 App 不会自动更新服务端。请核对当前连接的服务地址;本页面会保留,更新后点击刷新。</p>
-   <template v-else-if="tab==='computers'">
-    <p v-if="!computers.length&&!loading">还没有 Agent。先在 Bot 模式新建 Agent,再在这里启用隔离电脑。</p>
-    <article v-for="row in computers" :key="row.agent.id"><h3>{{row.agent.name}}</h3><p>{{row.agent.computerEnvironmentName?'共享电脑 · '+row.agent.computerEnvironmentName:row.agent.execution==='computer'?'独立电脑':'尚未配置电脑'}}</p><p>{{row.reason}}</p><div class="actions"><button v-if="row.agent.execution==='computer'" :disabled="!row.available||busy" @click="viewing=row.agent">查看与接管</button><button v-else :disabled="busy||!!row.agent.remoteAgentId" @click="enable(row.agent)">启用隔离电脑</button><button v-if="!row.available" @click="tab=canManageImages?'nodes':'images'">查看配置指引</button></div></article>
-   </template>
-   <template v-else-if="tab==='shared'">
-    <p>共享成员可以访问同一工作区与浏览器登录资料;电脑由一名操作者使用,其他任务排队。</p>
-    <p v-if="!shared.length">还没有共享环境。默认每个 Agent 使用独立电脑。</p>
-    <article v-for="item in shared" :key="item.id"><h3>{{item.name}}</h3><p>{{item.memberIds.map(id=>computers.find(row=>row.agent.id===id)?.agent.name||id).join('、')}}</p><button :disabled="busy" @click="action(()=>apiRequest(`/api/app/computers/shared/${item.id}`,{method:'DELETE'}))">解除共享并保留数据</button></article>
-    <fieldset><legend>创建共享环境</legend><label>名称<input v-model="name" maxlength="100"></label><p v-if="candidates.length<2">至少需要两名使用同一来源和 Profile 的持久隔离 Agent。请先在电脑页完成配置。</p><label v-for="row in candidates" :key="row.agent.id" class="check"><input v-model="members" type="checkbox" :value="row.agent.id">{{row.agent.name}} · {{row.agent.profile}}</label><label class="check"><input v-model="trusted" type="checkbox">我确认这些成员可以共享工作文件和浏览器登录资料</label><button :disabled="busy||members.length<2||members.length>8||!name.trim()||!trusted" @click="share">创建共享环境</button></fieldset>
-   </template>
-   <template v-else-if="tab==='images'"><ImageManagementPanel v-if="canManageImages"/><p v-else>镜像管理需要服务器管理员权限。你仍可在电脑页查看和接管自己的电脑,在共享环境页管理自己的成员。</p></template>
-   <template v-else><RunnerSettingsPanel v-if="canManageImages"/><p v-else>请让管理员注册执行节点、启用隔离电脑并分配 Profile,然后刷新此页面。</p></template>
-  </main>
- </dialog><ComputerPanel v-if="viewing" :agents="[viewing]" @close="viewing=undefined" @changed="refresh"/></Teleport>
-</template>
-<style scoped>
-.environment-hub{width:min(900px,calc(100vw - 24px));max-height:calc(100dvh - 24px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary)}.environment-hub::backdrop{background:#0007}header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px;border-bottom:1px solid var(--line)}h2,h3,p{margin:0}h2{font-size:18px}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}nav,.actions{display:flex;gap:8px;flex-wrap:wrap}nav{padding:12px 18px;border-bottom:1px solid var(--line)}main{display:grid;gap:16px;padding:18px;min-width:0}article,fieldset{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}label{display:grid;gap:8px}.check{display:flex;align-items:center;min-height:44px}.check input{width:18px;height:18px}button,input{font:inherit;color:inherit}button,input:not([type=checkbox]){min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised)}button{cursor:pointer}button[aria-pressed=true]{border-color:var(--accent);background:var(--surface-soft)}button:disabled{opacity:.5}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}@media(max-width:600px){.environment-hub{width:100vw;max-height:100dvh;border-radius:0}main,header,nav{padding:12px}}
-</style>

+ 61 - 46
src/client/components/workspace/ComputerPanel.vue

@@ -5,18 +5,18 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[];changed:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
-const managing=ref(false),shareName=ref('团队共享电脑'),shareMembers=ref<string[]>([]),trusted=ref(false)
+const controlsOpen=ref(false)
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
-const candidates=computed(()=>props.agents.filter(agent=>!agent.temporaryGoalId&&!agent.computerEnvironmentId&&!agent.archived))
-const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
+const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
 let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
-const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
-const controllable=computed(()=>mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation&&!busy.value)
+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)
 const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
 const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
 async function refresh(){
 async function refresh(){
@@ -26,90 +26,105 @@ async function refresh(){
     const status=await apiRequest<ComputerControlStatus>(base)
     const status=await apiRequest<ComputerControlStatus>(base)
     if(current!==generation||closed)return
     if(current!==generation||closed)return
     state.value=status
     state.value=status
+    connectionOK.value=true
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
     if(status.mode!=='off'){
     if(status.mode!=='off'){
       const image=await apiRequest<ComputerFrame>(base+'/frame')
       const image=await apiRequest<ComputerFrame>(base+'/frame')
       if(current===generation&&!closed)frame.value=image
       if(current===generation&&!closed)frame.value=image
     }
     }
-  }catch(cause){if(current===generation&&!closed){error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
+  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
   finally{polling=false}
   finally{polling=false}
 }
 }
 async function run(action:()=>Promise<void>){
 async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
   if(busy.value)return;generation++;busy.value=true;error.value=''
-  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh()}
+  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
 }
 }
-function take(){void run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+function take(){return run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
 function send(action:ComputerInput){
 function send(action:ComputerInput){
-  if(!controllable.value||!frame.value||!ticket.value)return
-  const image=frame.value,credentials=ticket.value
-  void run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})})
+  if(!canSend.value||closed)return
+  if(pendingClick){inputs.push(pendingClick);pendingClick=undefined;clearTimeout(clickTimer)}
+  const last=inputs.at(-1)
+  if(action.kind==='text'&&last?.kind==='text'&&last.text.length+action.text.length<=16000)last.text+=action.text
+  else if(inputs.length<64)inputs.push(action)
+  else {error.value='输入过快,请稍后再试';return}
+  void flushInputs()
 }
 }
+async function flushInputs(){
+  if(flushing||busy.value||!inputs.length||closed)return
+  if(!canSend.value||!frame.value||!ticket.value){inputs.length=0;return}
+  flushing=true
+  const action=inputs.shift()!,image=frame.value,credentials=ticket.value
+  try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
+  finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
+}
+function point(event:MouseEvent|PointerEvent){const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!;return {x:Math.min(f.width-1,Math.max(0,Math.floor((event.clientX-r.left)*f.width/r.width))),y:Math.min(f.height-1,Math.max(0,Math.floor((event.clientY-r.top)*f.height/r.height)))}}
+let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
+function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;const p=point(event);dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
+function up(event:PointerEvent){if(!dragStart)return;const start=dragStart;dragStart=undefined;if(!canSend.value)return;if(Math.hypot(event.clientX-start.clientX,event.clientY-start.clientY)>5){const end=point(event);dragged=true;send({kind:'drag',fromX:start.x,fromY:start.y,toX:end.x,toY:end.y})}}
+function wheel(event:WheelEvent){if(!canSend.value)return;event.preventDefault();send({kind:'scroll',direction:Math.abs(event.deltaX)>Math.abs(event.deltaY)?event.deltaX>0?'right':'left':event.deltaY>0?'down':'up',amount:Math.min(20,Math.max(1,Math.ceil(Math.abs(event.deltaY||event.deltaX)/80)))})}
+function keydown(event:KeyboardEvent){if(!canSend.value)return;event.preventDefault();event.stopPropagation();if(event.key==='Escape'){(event.currentTarget as HTMLElement).blur();return}const modifiers=[event.ctrlKey?'ctrl':'',event.altKey?'alt':'',event.shiftKey?'shift':'',event.metaKey?'super':''].filter(Boolean);if(event.key.length===1&&!event.ctrlKey&&!event.altKey&&!event.metaKey)send({kind:'text',text:event.key});else if(!['Control','Alt','Shift','Meta'].includes(event.key))send({kind:'key',key:({Enter:'Return',ArrowUp:'Up',ArrowDown:'Down',ArrowLeft:'Left',ArrowRight:'Right',Backspace:'BackSpace'} as Record<string,string>)[event.key]??event.key,modifiers})}
 function click(event:MouseEvent){
 function click(event:MouseEvent){
-  if(!controllable.value||!frame.value)return
+  if(dragged){dragged=false;return}
+  if(!canSend.value||!frame.value)return
   const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
   const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
-  send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
+  const action:ComputerInput={kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1}
+  if(event.button===2||event.detail>1){clearTimeout(clickTimer);pendingClick=undefined;send(action)}
+  else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
 }
 }
-function manage(){managing.value=!managing.value;shareMembers.value=candidates.value.map(agent=>agent.id);trusted.value=false}
-function share(){void run(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:shareName.value,memberIds:shareMembers.value,trusted:trusted.value}});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
-function unshare(){const id=selectedAgent.value?.computerEnvironmentId;if(!id)return;void run(async()=>{await apiRequest(`/api/app/computers/shared/${encodeURIComponent(id)}`,{method:'DELETE'});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
-async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
+async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded)dialog.value?.close();emit('close')}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
-watch(selected,()=>{generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
-onMounted(()=>{dialog.value?.showModal();void cycle()})
-onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
+watch(selected,()=>{inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
+onMounted(()=>{if(!props.embedded)dialog.value?.showModal();void cycle();if(props.autoTake)void take()})
+onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
+defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
 </script>
 </script>
 <template>
 <template>
-  <Teleport to="body">
-    <dialog ref="dialog" class="computer-panel" aria-label="隔离电脑" @cancel.prevent="close">
+  <Teleport to="body" :disabled="embedded">
+    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
       <header>
       <header>
         <div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
         <div><strong>隔离电脑</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>
         <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>{{ agents[0]?.name }}</span>
-        <button type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
+        <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
+        <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
+        <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
+        <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
+        <button v-if="!embedded" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
       </header>
       </header>
-      <div class="computer-screen" :class="{controlling:controllable}">
-        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" @click="click" @contextmenu.prevent="click">
+      <div class="computer-screen" :class="{controlling:canSend}">
+        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
-        <span v-if="frame&&!controllable" class="view-label">{{ state.mode==='off'?'上次画面':'仅查看' }}</span>
+        <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
       </div>
       <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
       <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
-      <footer>
+      <footer v-if="!embedded||controlsOpen">
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
-        <button v-if="!ticket&&(candidates.length>1||selectedAgent?.computerEnvironmentId)" type="button" :disabled="busy" @click="manage">{{ managing?'返回电脑':'电脑共享设置' }}</button>
-        <section v-if="managing" class="sharing-settings" aria-label="电脑共享设置">
-          <template v-if="selectedAgent?.computerEnvironmentId">
-            <p>解除共享后,成员回到各自原有的独立电脑;共享工作区保留,可由执行节点备份或恢复。</p>
-            <button type="button" :disabled="busy" @click="unshare">解除这台电脑的共享</button>
-          </template>
-          <template v-else>
-            <label>共享电脑名称<input v-model="shareName" maxlength="100"></label>
-            <fieldset><legend>选择可信成员(同一来源和 Profile)</legend><label v-for="agent in candidates" :key="agent.id" class="share-option"><input v-model="shareMembers" type="checkbox" :value="agent.id">{{ agent.name }} · {{ agent.profile }}</label></fieldset>
-            <p>新建共享工作区,原独立电脑的文件仍然保留。请先停止所选成员的任务并交还电脑控制权。</p>
-            <label class="share-option"><input v-model="trusted" type="checkbox">我确认这些成员可以访问同一工作区和浏览器登录资料</label>
-            <button type="button" :disabled="busy||!trusted||shareMembers.length<2||shareMembers.length>8||!shareName.trim()" @click="share">创建可信共享电脑</button>
-          </template>
-        </section>
         <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
         <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
-        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
+        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
         <template v-if="mine">
+          <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
+          <section v-if="controlsOpen" class="manual-input">
           <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
           <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
+          <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:['ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:['ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:['ctrl']})">粘贴</button></div>
           <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
           <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>
           <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>
           <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>
           <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
           <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
           <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
         </template>
         </template>
       </footer>
       </footer>
-    </dialog>
+    </component>
   </Teleport>
   </Teleport>
 </template>
 </template>
 <style scoped>
 <style scoped>
+.computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}
 .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
 .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
 .computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
 .computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
 button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
-.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
+.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen img:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
 footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
 footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
 .sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
 .sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
 @media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
 @media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}

+ 0 - 63
src/client/components/workspace/ImageManagementPanel.vue

@@ -1,63 +0,0 @@
-<script setup lang="ts">
-import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
-import AppIcon from '@/components/common/AppIcon.vue'
-import {apiRequest} from '@/api/client'
-import {createUuid} from '@/utils/id'
-import type {ImageInventory,ImageOperation} from '@shared/imageManagement'
-const runners=ref<Array<{id:string;name:string;online:boolean;enabled:boolean}>>([]),selected=ref(''),inventory=ref<ImageInventory>(),error=ref(''),busy=ref(false),loading=ref(false),progress=ref('')
-const archive=ref<File>(),manifest=ref<File>(),uploadId=ref('');let closed=false,timer:ReturnType<typeof setTimeout>,generation=0
-const base=()=>`/api/app/admin/runners/${encodeURIComponent(selected.value)}/images`
-const names:Record<ImageOperation,string>={prepare:'准备兼容镜像',verify:'校验',activate:'应用',rollback:'回退',import:'导入',export:'导出'}
-const activeImage=computed(()=>inventory.value?.images.find(image=>image.imageId===inventory.value?.activeImageId))
-const readiness=computed(()=>error.value?'状态读取失败':inventory.value?.busy?'正在维护镜像':activeImage.value?'桌面镜像已应用':inventory.value?.images.length?'镜像已准备,等待应用':'尚未准备桌面镜像')
-async function refresh(){if(!selected.value||busy.value||closed)return;const current=++generation;loading.value=true;try{const data=await apiRequest<ImageInventory>(base());if(current===generation)inventory.value=data}catch(e){if(current===generation)error.value=e instanceof Error?e.message:'无法读取镜像'}finally{loading.value=false}}
-async function action(operation:ImageOperation,imageId?:string){if(busy.value)return;busy.value=true;error.value='';try{await apiRequest(base()+'/jobs',{method:'POST',body:{requestId:createUuid(),operation,...(imageId?{imageId}:{}),...(operation==='import'?{uploadId:uploadId.value}:{})}})}catch(e){error.value=e instanceof Error?e.message:'镜像操作失败'}finally{busy.value=false;await refresh()}}
-async function sendChunk(endpoint:string,body:{offset:number;data:string}){
- for(let attempt=0;;attempt++){if(closed)throw new Error('上传已取消');try{return await apiRequest<{offset:number}>(endpoint,{method:'POST',body})}catch(error){const status=Number((error as any).status);if(attempt>=2||(status>=400&&status<500))throw error;await new Promise(resolve=>setTimeout(resolve,300*(attempt+1)))}}
-}
-async function upload(){
- if(!archive.value||!manifest.value||busy.value)return;busy.value=true;error.value='';const file=archive.value;const endpoint=base();let success=false
- try{
-  if(file.size>20*1024**3||manifest.value.size>16000)throw new Error('镜像上限 20 GiB;请选择配套的 JSON 清单')
-  const metadata=JSON.parse(await manifest.value.text());uploadId.value=createUuid()
-  await apiRequest(endpoint+'/uploads',{method:'POST',body:{id:uploadId.value,size:file.size,manifest:metadata}})
-  for(let offset=0;offset<file.size;){if(closed)throw new Error('上传已取消');const bytes=new Uint8Array(await file.slice(offset,offset+524288).arrayBuffer());let binary='';for(const byte of bytes)binary+=String.fromCharCode(byte)
-   const next=await sendChunk(endpoint+'/uploads/'+uploadId.value,{offset,data:btoa(binary)});if(next.offset<=offset)throw new Error('上传未能继续');offset=next.offset;progress.value=`正在上传 ${Math.round(offset/file.size*100)}%`
-  }success=true;progress.value='上传完成,正在提交导入'
- }catch(e){error.value=e instanceof Error?e.message:'上传失败';if(uploadId.value)await apiRequest(endpoint+'/uploads/'+uploadId.value,{method:'DELETE'}).catch(()=>{})}
- finally{busy.value=false}
- if(success){await action('import');archive.value=undefined;manifest.value=undefined;progress.value=''}
-}
-function choose(event:Event,kind:'archive'|'manifest'){const file=(event.target as HTMLInputElement).files?.[0];if(kind==='archive')archive.value=file;else manifest.value=file}
-async function poll(){await refresh();if(!closed)timer=setTimeout(poll,2000)}
-onMounted(async()=>{try{runners.value=(await apiRequest<{runners:typeof runners.value}>('/api/app/admin/runners')).runners.filter(r=>r.enabled);selected.value=runners.value[0]?.id??'';void poll()}catch(e){error.value=e instanceof Error?e.message:'无法读取节点'}})
-onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
-</script>
-<template>
- <section class="image-manager" aria-label="镜像管理">
-  <div class="image-intro"><span class="desktop-icon"><AppIcon name="monitor" :size="24"/></span><div><h3>虚拟桌面镜像</h3><p>为 Bot 准备独立或共享的桌面环境,手机端可查看画面与接管操作。</p></div></div>
-  <p v-if="!runners.length&&!error">尚未注册执行节点。请先在“节点配置”中注册并连接 Runner,镜像 ID 可以稍后设置。</p>
-  <label v-else>执行节点<select v-model="selected" :disabled="busy||inventory?.busy" @change="inventory=undefined;error='';refresh()"><option v-for="runner in runners" :key="runner.id" :value="runner.id">{{runner.name}}{{runner.online?'':'(离线)'}}</option></select></label>
-  <p v-if="error" role="alert">{{error}}</p>
-  <template v-if="selected">
-   <section v-if="inventory" class="image-summary" aria-label="桌面镜像状态">
-    <strong>{{readiness}}</strong>
-    <p v-if="activeImage">Linux · {{activeImage.architecture}} · 当前镜像 {{activeImage.imageId.slice(0,19)}}…</p>
-    <p v-else>准备或导入镜像后,点击“应用”供这个节点上的 Bot 使用。</p>
-    <ol class="setup-steps" aria-label="镜像准备流程"><li :class="{done:inventory.images.length}">1 · 准备镜像</li><li :class="{done:activeImage}">2 · 应用到节点</li><li>3 · 打开 Bot 电脑</li></ol>
-   </section>
-   <div class="actions"><button :disabled="busy||inventory?.busy" @click="action('prepare')">准备兼容镜像</button><button :disabled="busy||loading" @click="error='';refresh()">刷新</button><button v-if="inventory?.previousImageId" :disabled="busy||inventory?.busy" @click="action('rollback')">回退到上一镜像</button></div>
-   <p>操作在节点后台继续。应用或回退保留工作区;维护前请停止电脑任务并交还控制权。</p>
-   <p v-if="loading&&!inventory" role="status">正在读取节点镜像…</p>
-   <p v-if="inventory&&!inventory.images.length">节点还没有兼容镜像。可以在线准备,或导入已有镜像和校验清单。</p>
-   <article v-for="image in inventory?.images" :key="image.imageId" class="image-card"><strong>{{image.architecture}} · CUA {{image.driver}}</strong><code>{{image.imageId}}</code><span v-if="image.imageId===inventory?.activeImageId">当前使用</span><div class="actions"><button :disabled="busy||inventory?.busy" @click="action('verify',image.imageId)">校验</button><button :disabled="busy||inventory?.busy||image.imageId===inventory?.activeImageId" @click="action('activate',image.imageId)">应用</button><button :disabled="busy||inventory?.busy" @click="action('export',image.imageId)">导出</button></div></article>
-   <article v-for="item in inventory?.uploads" :key="item.id" class="image-card"><p>未完成上传:{{Math.round(item.offset/item.size*100)}}%</p><button :disabled="busy||inventory?.busy" @click="apiRequest(`${base()}/uploads/${item.id}`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">取消并清理上传</button></article>
-   <fieldset><legend>导入离线镜像</legend><label>镜像文件(.tar)<input type="file" accept=".tar" :disabled="busy||inventory?.busy" @change="choose($event,'archive')"></label><label>校验清单(.tar.json)<input type="file" accept=".json" :disabled="busy||inventory?.busy" @change="choose($event,'manifest')"></label><button :disabled="busy||inventory?.busy||!archive||!manifest" @click="upload">上传并导入</button><p v-if="progress" role="status">{{progress}}</p></fieldset>
-   <section v-if="inventory?.jobs.length" aria-label="镜像操作记录"><h3>操作记录</h3><article v-for="job in inventory.jobs" :key="job.id" class="image-card"><strong>{{names[job.operation]}} · {{({running:'进行中',complete:'完成',failed:'失败',uncertain:'待核对'})[job.state]}}</strong><p role="status">{{job.message}}</p><div v-if="job.operation==='export'&&job.state==='complete'&&job.archiveSize" class="actions"><a :href="`${base()}/jobs/${job.id}/archive`" download>下载镜像</a><a :href="`${base()}/jobs/${job.id}/manifest`" download>下载校验清单</a><button @click="apiRequest(`${base()}/jobs/${job.id}/archive`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">清理导出文件</button></div></article></section>
-  </template>
- </section>
-</template>
-<style scoped>
-.image-intro{display:flex;gap:14px;align-items:center}.image-intro h3{margin:0 0 6px;font-size:17px}.desktop-icon{display:grid;place-items:center;width:48px;height:48px;flex:none;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft);color:var(--accent)}.image-summary{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)}.setup-steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:4px 0 0;padding:0;font-size:12px;color:var(--text-muted)}.setup-steps li{padding:8px 10px;border:1px solid var(--line);border-radius:8px}.setup-steps .done{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line))}
-.image-manager,.image-card,fieldset,label{display:grid;gap:12px}.image-card,fieldset{padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}.actions{display:flex;gap:8px;flex-wrap:wrap}p{font-size:13px;line-height:1.6;margin:0;color:var(--text-secondary)}code{overflow-wrap:anywhere;font-size:12px}button,select,input,a{font:inherit;min-height:44px;color:var(--text-primary)}button,select,a{border:1px solid var(--line);border-radius:8px;padding:8px 12px;background:var(--surface-raised)}a{display:inline-flex;align-items:center;text-decoration:none}button:disabled{opacity:.5}button{cursor:pointer}:is(button,select,input,a):focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}
-</style>

Plik diff jest za duży
+ 68 - 0
src/client/components/workspace/LocalVmChatPanel.vue


+ 32 - 0
src/client/components/workspace/LocalVmWorkspace.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+import {ref,computed} from 'vue'
+import type {WorkspaceAgent} from '@shared/workspace'
+import ComputerPanel from './ComputerPanel.vue'
+const props=defineProps<{agents:WorkspaceAgent[];primary:string}>(),emit=defineEmits<{close:[]}>()
+const slots=ref([props.primary,props.agents.find(a=>a.id!==props.primary)?.id??''])
+const panels:Array<InstanceType<typeof ComputerPanel>|null>=[]
+const busy=ref(false),error=ref(''),controlled=ref<number>()
+const choices=computed(()=>props.agents.filter(a=>!a.computerEnvironmentId))
+async function release(){for(const panel of panels)if(panel?.hasControl())await panel.releaseControl();controlled.value=undefined}
+async function take(index:number){
+ if(busy.value)return;busy.value=true;error.value=''
+ try{await release();await panels[index]?.take();if(panels[index]?.hasControl())controlled.value=index}catch(e){error.value=e instanceof Error?e.message:'切换控制失败'}finally{busy.value=false}
+}
+async function select(index:number,value:string){if(busy.value)return;busy.value=true;error.value='';try{await release();slots.value[index]=value}catch(e){error.value=String(e)}finally{busy.value=false}}
+async function close(){if(busy.value)return false;busy.value=true;try{await release();emit('close');return true}catch(e){error.value=String(e);return false}finally{busy.value=false}}
+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>
+  <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>
+   <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>
+  </section></div>
+ </main>
+</template>
+<style scoped>
+.local-vm-workspace{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;max-height:none;margin:0;padding:0;border:0;background:var(--surface);color:var(--text-primary)}.local-vm-workspace::backdrop{background:#0009}header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}p{font-size:12px;color:var(--text-secondary);line-height:1.6;margin:6px 0 0}.panes{display:grid;grid-template-columns:1fr 1fr;flex:1;min-height:0;gap:12px;padding:12px;background:var(--surface-soft)}.pane{display:flex;min-width:0;min-height:0;flex-direction:column;background:var(--surface);border:1px solid var(--line);border-radius:10px;overflow:hidden}.pane>label{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;font-size:13px;border-bottom:1px solid var(--line)}.controlled{outline:2px solid var(--accent);outline-offset:-2px}button,select{min-height:40px;padding:7px 12px;font:inherit;border:1px solid var(--line);border-radius:8px;color:inherit;background:var(--surface-raised)}button:disabled,select:disabled{opacity:.5}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.empty{padding:30px}.panes:has(+[role=alert]){height:auto}[role=alert]{color:var(--danger);padding:0 20px}@media(max-width:800px){.panes{grid-template-columns:1fr;height:auto}.local-vm-workspace{overflow:auto}.pane{min-height:70vh}}
+</style>

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

@@ -2,11 +2,12 @@
 import { publishServerIdentity } from '@/api/serverIdentity'
 import { publishServerIdentity } from '@/api/serverIdentity'
 import type { ServerIdentity } from '@shared/serverIdentity'
 import type { ServerIdentity } from '@shared/serverIdentity'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
-import ComputerEnvironmentHub from '@/components/workspace/ComputerEnvironmentHub.vue'
+import LocalVmChatPanel from '@/components/workspace/LocalVmChatPanel.vue'
+import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
 import type { AgentAssignment } from '@shared/agentTasks'
@@ -186,8 +187,14 @@ let cursor = 0,
   pendingFingerprint = ''
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
-const environmentOpen=ref(false)
-const computerOpen=ref(false)
+const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
+const computerOpen=ref(false),computerDockOpen=ref(false),twoDesktops=ref(false),desktopAgent=ref<Agent>()
+async function leaveComputer(){
+  if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
+  if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
+  return true
+}
+onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
 const computerAgents=computed(()=>agents.value.filter(agent=>agent.execution==='computer'&&!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===selectedTask.value))))
 const computerAgents=computed(()=>agents.value.filter(agent=>agent.execution==='computer'&&!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===selectedTask.value))))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
@@ -624,7 +631,7 @@ onBeforeUnmount(() => {
 })
 })
 </script>
 </script>
 <template>
 <template>
-  <WorkspaceShell :server-name="auth.serverIdentity?.displayName"
+  <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
     :user-name="auth.user?.username"
     :user-name="auth.user?.username"
     :user-avatar="auth.user?.avatar"
     :user-avatar="auth.user?.avatar"
     :profiles="auth.profiles"
     :profiles="auth.profiles"
@@ -649,11 +656,12 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
     @create-remote-agent="remotePickerOpen = true"
   >
   >
     <template #sidebar
     <template #sidebar
-      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
     <template #mobile-sidebar
     <template #mobile-sidebar
-      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
+    <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
@@ -671,7 +679,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-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>
         <template #header-actions>
           <div v-if="active" class="header-actions">
           <div v-if="active" class="header-actions">
-            <button v-if="computerAgents.length" type="button" @click="computerOpen=true">电脑</button>
+            <button type="button" class="icon-button" aria-label="电脑" title="电脑" @click="computerDockOpen=!computerDockOpen"><AppIcon name="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}})">
             <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>
               <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
             </select>
             </select>
@@ -690,8 +698,10 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         :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" />
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
     </section>
-    <ComputerEnvironmentHub v-if="environmentOpen" @close="environmentOpen=false" @changed="refresh()" />
-    <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
+    <LocalVmChatPanel v-if="computerDockOpen" :active="!twoDesktops" :agents="members" :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" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
@@ -740,14 +750,6 @@ onBeforeUnmount(() => {
             "
             "
           />
           />
         </label>
         </label>
-        <label v-if="isAgentDialog && !isRemoteAgent">
-          执行环境
-          <select v-model="form.execution" aria-label="执行环境">
-            <option value="profile">基础 Profile 环境</option>
-            <option value="computer">隔离电脑</option>
-          </select>
-          <small v-if="form.execution === 'computer'">文件、终端和桌面操作在独立电脑中执行。需先配置支持电脑的 Runner,并为基础 Profile 设置绝对工作目录。联网范围由执行节点的配置决定。</small>
-        </label>
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
         <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>
           <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">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
@@ -832,7 +834,7 @@ onBeforeUnmount(() => {
   </WorkspaceShell>
   </WorkspaceShell>
 </template>
 </template>
 <style scoped>
 <style scoped>
-.environment-entry{padding:8px 12px}.environment-entry button{width:100%;min-height:44px;text-align:left;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.environment-entry button:focus-visible{outline:2px solid var(--accent)}
+.conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .editor {
 .editor {
   border: 1px solid var(--line);
   border: 1px solid var(--line);

+ 36 - 29
src/runner/agent.ts

@@ -2,7 +2,7 @@ import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
 import { DatabaseSync } from 'node:sqlite'
 import { realpath,access } from 'node:fs/promises'
 import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
 import { resolve, sep, join } from 'node:path'
-import {ImageManagement} from './computers/imageManagement.js'
+import {LocalVmImages} from './computers/localVm.js'
 import {ComputerError} from './computers/container.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 import {z} from 'zod'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
@@ -32,7 +32,7 @@ export class RunnerAgent {
   private inflight=new Map<string,Promise<unknown>>()
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
   private db:DatabaseSync
   private computers?:ComputerRuntime
   private computers?:ComputerRuntime
-  private imageManagement?:ImageManagement
+  private localVm?:LocalVmImages
   private target:GatewayTarget
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
   private serverEpoch?:string
@@ -46,7 +46,7 @@ export class RunnerAgent {
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
-    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.imageManagement=new ImageManagement(this.computers,this.db,home)}
+    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.localVm=new LocalVmImages(this.computers,this.db)}
     this.compactReceipts()
     this.compactReceipts()
   }
   }
   private async api(path:string,body?:unknown):Promise<any> {
   private async api(path:string,body?:unknown):Promise<any> {
@@ -54,7 +54,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,image-management-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
@@ -90,25 +90,42 @@ export class RunnerAgent {
   private async execute(command:RunnerCommand):Promise<any> {
   private async execute(command:RunnerCommand):Promise<any> {
     const p=command.payload
     const p=command.payload
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
-    if(command.kind==='images.manage'){
-      if(!this.imageManagement)throw new HttpError(409,'执行节点尚未启用隔离电脑,请更新节点配置','computer_unavailable')
-      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','start','upload','append','cancel-upload','download','remove-export']),id:z.string().uuid().optional(),operation:z.enum(['prepare','verify','activate','rollback','import','export']).optional(),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional(),manifest:z.unknown().optional(),size:z.number().optional(),offset:z.number().optional(),data:z.string().max(750000).optional()}).strict().parse(p)
-      const check=async()=>{if(!this.connected||!this.active||(await this.api('image-check',{id:input.id})).allowed!==true)throw new ComputerError('image_authorization_revoked','镜像管理授权已失效')}
-      if(input.op==='status')return this.imageManagement.inventory(input.owner)
-      if(input.op==='start')return this.imageManagement.start(input.owner,input.id!,input.operation!,{...(input.imageId?{imageId:input.imageId}:{}),...(input.uploadId?{uploadId:input.uploadId}:{})},check)
-      if(input.op==='upload')return this.imageManagement.beginUpload(input.owner,input.id!,input.size!,input.manifest)
-      if(input.op==='append')return this.imageManagement.appendUpload(input.owner,input.id!,input.offset!,input.data!)
-      if(input.op==='cancel-upload')return this.imageManagement.cancelUpload(input.owner,input.id!)
-      if(input.op==='remove-export')return this.imageManagement.removeExport(input.owner,input.id!)
-      return this.imageManagement.download(input.owner,input.id!,input.offset!)
+    if(command.kind==='local-vm.manage'){
+      if(!this.localVm)throw new HttpError(409,'本地虚拟机尚未配置','computer_unavailable')
+      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','instance']),id:z.string().uuid().optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
+      const check=async()=>{if(!this.connected||!this.active||(await this.api('local-vm-check',{id:input.id})).allowed!==true)throw new ComputerError('local_vm_authorization_revoked','本地虚拟机配置授权已失效')}
+      if(input.op==='status')return this.localVm.status(input.owner)
+      if(input.op==='instance'){
+        await check()
+        const spec=this.computers!.pool.definition(input.owner,input.environmentId!)
+        if(spec)await this.computers!.pool.desktop(spec,input.action!,()=>{if(!this.active||!this.connected)throw new Error('节点已断开')})
+        await check();return {ok:true}
+      }
+      if(input.op==='prepare')return this.localVm.prepare(input.id!,check)
+      await check();return this.localVm.policy(input.mode!,input.maxInstances!)
     }
     }
     if(command.kind==='computer.control'){
     if(command.kind==='computer.control'){
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
-      const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
+      const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','detail','lifecycle','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
       this.requireProfile(input.profile)
       this.requireProfile(input.profile)
       const check=async()=>{if(!this.connected||!input.controlId||(await this.api('control-check',{controlId:input.controlId})).allowed!==true)throw new HttpError(403,'电脑控制授权已失效','computer_control_expired')}
       const check=async()=>{if(!this.connected||!input.controlId||(await this.api('control-check',{controlId:input.controlId})).allowed!==true)throw new HttpError(403,'电脑控制授权已失效','computer_control_expired')}
       const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
       const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
       await this.computers.ready
       await this.computers.ready
+      if(input.op==='detail'){
+        const vm=await this.localVm!.status(),spec=this.computers.pool.definition(input.target.ownerKey,input.target.environmentId)
+        const state=await this.computers.controls.status(input.target)
+        const actual=spec?await this.computers.provider.inspect(spec):undefined
+        let ready=false
+        if(actual?.running)try{await this.computers.provider.health(spec!,authorize);ready=true}catch{}
+        return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,image:vm.image,controlMode:state.mode}
+      }
+      if(input.op==='lifecycle'){
+        const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
+        this.computers.assertTarget(input.target)
+        const resolved=await this.computers.resolve(input.profile);authorize()
+        await this.computers.pool.desktop({id:input.target.environmentId,ownerKey:input.target.ownerKey,imageId:this.computers.config.imageId,cwd:resolved.cwd,network:this.computers.config.network??'none'},action,authorize)
+        return {ok:true}
+      }
       if(input.op==='status')return this.computers.controls.status(input.target)
       if(input.op==='status')return this.computers.controls.status(input.target)
       if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
       if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
       if(input.op==='take')return this.computers.controls.take(input.target,input.profile,input.controlId!,check)
       if(input.op==='take')return this.computers.controls.take(input.target,input.profile,input.controlId!,check)
@@ -229,18 +246,6 @@ export class RunnerAgent {
     const existing=this.inflight.get(command.id)
     const existing=this.inflight.get(command.id)
     const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
     const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
     const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
     const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
-    // Export chunks are immutable reads. Retain the fingerprint, not a second
-    // copy of a multi-gigabyte archive in the receipt database.
-    if(command.kind==='images.manage'&&command.payload.op==='download'){
-      if(previous&&previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
-      if(existing)return existing
-      if(!previous)this.db.prepare("INSERT INTO commands VALUES(?,?,'read',NULL,?)").run(command.id,fingerprint,Date.now())
-      const pending=Promise.resolve().then(async()=>{
-        if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'读取授权已失效','runner_command_not_admitted')
-        return {result:await this.execute(command)}
-      }).catch(error=>({error:{message:error instanceof ComputerError||error instanceof HttpError?error.message:'镜像读取失败',code:error instanceof ComputerError||error instanceof HttpError?error.code:'runner_error'}})).finally(()=>this.inflight.delete(command.id))
-      this.inflight.set(command.id,pending);return pending
-    }
     if(previous){
     if(previous){
       if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
       if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
       if(existing)return existing
       if(existing)return existing
@@ -260,6 +265,7 @@ export class RunnerAgent {
   private status(message:string){if(message!==this.lastStatus){this.lastStatus=message;this.onStatus(message)}}
   private status(message:string){if(message!==this.lastStatus){this.lastStatus=message;this.onStatus(message)}}
   async run(signal:AbortSignal):Promise<void> {
   async run(signal:AbortSignal):Promise<void> {
     const deliveries=new Map<string,Promise<void>>()
     const deliveries=new Map<string,Promise<void>>()
+    const cleanupTimer=setInterval(()=>{void this.computers?.pool.expire().catch(()=>{})},5000);cleanupTimer.unref()
     const abort=()=>{this.active=false;this.controlAbort.abort();void this.disconnect()};signal.addEventListener('abort',abort,{once:true})
     const abort=()=>{this.active=false;this.controlAbort.abort();void this.disconnect()};signal.addEventListener('abort',abort,{once:true})
     try {
     try {
       while(this.active&&!signal.aborted) {
       while(this.active&&!signal.aborted) {
@@ -287,11 +293,12 @@ export class RunnerAgent {
         }
         }
       }
       }
     }finally{
     }finally{
+      clearInterval(cleanupTimer)
       signal.removeEventListener('abort',abort)
       signal.removeEventListener('abort',abort)
       try{
       try{
         await this.disconnect().catch(()=>{})
         await this.disconnect().catch(()=>{})
         await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
         await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
-        await this.imageManagement?.close();await this.computers?.controls.close();await this.computers?.pool.close()
+        await this.localVm?.close();await this.computers?.controls.close();await this.computers?.pool.close()
       }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
       }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
     }
     }
   }
   }

+ 0 - 142
src/runner/computers/imageManagement.ts

@@ -1,142 +0,0 @@
-import {mkdir,open,rm,stat,writeFile} from 'node:fs/promises'
-import {join,dirname,resolve} from 'node:path'
-import {existsSync} from 'node:fs'
-import {createHash} from 'node:crypto'
-import type {DatabaseSync} from 'node:sqlite'
-import {z} from 'zod'
-import {ComputerImages,imageManifest} from './images.js'
-import {ComputerError} from './container.js'
-import type {ComputerRuntime} from '../worker/gateway.js'
-import type {ImageJob,ImageInventory,ImageOperation} from '../../shared/imageManagement.js'
-const chunkSize=512*1024,maxSize=20*1024**3
-const digest=(v:unknown)=>createHash('sha256').update(JSON.stringify(v)).digest('hex')
-interface Upload {id:string;owner:string;size:number;offset:number;manifest:Record<string,unknown>;createdAt:number}
-interface StoredJob extends ImageJob {owner:string;fingerprint:string}
-/** Only administrator RPCs reach this manager; no host paths come from a model or browser. */
-export class ImageManagement {
-  private pending?:Promise<void>
-  private closed=false
-  private blocked?:{ids:string[];release:()=>void}
-  private repair?:Promise<void>
-  private directory:string
-  readonly images:ComputerImages
-  readonly ready:Promise<void>
-  constructor(readonly runtime:ComputerRuntime,readonly db:DatabaseSync,readonly home:string){
-    this.directory=join(home,'image-management');this.images=new ComputerImages(runtime.config.runtime)
-    db.exec('CREATE TABLE IF NOT EXISTS managed_image_jobs(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_uploads(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_selection(id INTEGER PRIMARY KEY,value TEXT NOT NULL)')
-    const selected=this.selection();if(selected?.base===runtime.config.imageId)runtime.config.imageId=selected.active;else if(selected)db.prepare('DELETE FROM managed_image_selection').run()
-    const release=runtime.pool.holdMaintenance();this.ready=runtime.ready.then(()=>this.recover()).then(()=>{if(this.blocked)this.blocked.release=release;else release()});void this.ready.catch(()=>{})
-  }
-  private selection():{base:string;active:string;previous?:string}|undefined{const row=this.db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as any;return row?JSON.parse(row.value):undefined}
-  private job(id:string):StoredJob|undefined {const row=this.db.prepare('SELECT value FROM managed_image_jobs WHERE id=?').get(id) as any;return row?JSON.parse(row.value):undefined}
-  private jobs():StoredJob[]{return (this.db.prepare('SELECT value FROM managed_image_jobs ORDER BY rowid DESC LIMIT 40').all() as any[]).map(row=>JSON.parse(row.value))}
-  private save(job:StoredJob){job.updatedAt=Date.now();this.db.prepare('INSERT OR REPLACE INTO managed_image_jobs VALUES(?,?)').run(job.id,JSON.stringify(job))}
-  private async recover(){
-    await mkdir(this.directory,{recursive:true,mode:0o700})
-    for(const job of this.jobs())if(job.state==='running'||job.state==='uncertain'){
-      job.state='uncertain';job.message='节点曾重启,原操作不会重复执行。请刷新列表或重新校验镜像。';this.save(job)
-      try{await this.cleanupCheck(job.id)}catch{this.blocked??={ids:[],release:()=>{}};this.blocked.ids.push(job.id);job.message='测试电脑停止尚未确认,刷新状态重试清理';this.save(job)}
-    }
-    for(const row of this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]){const u:Upload=JSON.parse(row.value);if(Date.now()-u.createdAt>2*3600000){await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)}}
-  }
-  private checkSpec(id:string,imageId=this.runtime.config.imageId){return {id,ownerKey:'image-management',imageId}}
-  private async cleanupCheck(id:string){const spec=this.checkSpec(id);await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}
-  private async retryCleanup(){
-    if(this.repair)return this.repair
-    const blocked=this.blocked;if(!blocked)return
-    this.repair=(async()=>{for(const id of blocked.ids)await this.cleanupCheck(id);if(this.blocked===blocked){this.blocked=undefined;blocked.release();for(const id of blocked.ids){const job=this.job(id);if(job){job.state='failed';job.message='测试电脑已清理,可重新校验镜像';this.save(job)}}}})().finally(()=>{this.repair=undefined})
-    return this.repair
-  }
-  async inventory(owner:string):Promise<ImageInventory>{
-    await this.ready;await this.retryCleanup().catch(()=>{})
-    let output:string
-    try{output=await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=com.openmausbot.cua-driver=0.20.0'])}catch{throw new ComputerError('image_runtime_unavailable','无法读取镜像,请确认执行节点上的 Docker/Podman 服务已启动')}
-    const images=[]
-    for(const id of [...new Set(output.trim().split(/\s+/).filter(Boolean))].slice(0,100))try{images.push(await this.images.inspect(id))}catch{}
-    return {configured:true,activeImageId:this.runtime.config.imageId,previousImageId:this.selection()?.previous,images,jobs:this.jobs().filter(j=>j.owner===owner).map(({owner:_o,fingerprint:_f,...j})=>j),busy:!!this.pending||!!this.blocked,uploads:(this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]).map(row=>JSON.parse(row.value) as Upload).filter(u=>u.owner===owner).map(u=>({id:u.id,size:u.size,offset:u.offset}))}
-  }
-  private uploadPath(id:string){return join(this.directory,id+'.tar')}
-  async beginUpload(owner:string,id:string,size:number,manifest:unknown){
-    z.string().uuid().parse(id);z.number().int().positive().max(maxSize).parse(size);const value=imageManifest.parse(manifest)
-    if(!value.archiveSha256)throw new ComputerError('image_manifest_invalid','请选择包含 SHA-256 的镜像校验清单')
-    await this.ready
-    const old=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any
-    if(old){const entry:Upload=JSON.parse(old.value);if(entry.owner!==owner||entry.size!==size||digest(entry.manifest)!==digest(value))throw new ComputerError('image_upload_conflict','上传编号已用于其他文件');return {id,offset:entry.offset}}
-    if(Number(this.db.prepare('SELECT count(*) AS n FROM managed_image_uploads').get()!.n)>=2)throw new ComputerError('image_upload_limit','请先完成或取消已有上传')
-    await writeFile(this.uploadPath(id),'',{flag:'wx',mode:0o600})
-    const entry:Upload={id,owner,size,manifest:value,offset:0,createdAt:Date.now()};this.db.prepare('INSERT INTO managed_image_uploads VALUES(?,?)').run(id,JSON.stringify(entry));return {id,offset:0}
-  }
-  private upload(owner:string,id:string):Upload{z.string().uuid().parse(id);const row=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any;const u:Upload|undefined=row?JSON.parse(row.value):undefined;if(!u||u.owner!==owner)throw new ComputerError('image_upload_missing','上传不存在或不属于当前账号');return u}
-  private uploading=new Set<string>()
-  async appendUpload(owner:string,id:string,offset:number,data:string){
-    const u=this.upload(owner,id);if(this.uploading.has(id))throw new ComputerError('image_upload_busy','上一块仍在写入')
-    const bytes=Buffer.from(z.string().max(750000).parse(data),'base64');z.number().int().nonnegative().parse(offset)
-    if(!bytes.length||bytes.length>chunkSize||offset+bytes.length>u.size)throw new ComputerError('image_upload_invalid','镜像数据块无效')
-    this.uploading.add(id)
-    try{
-      const file=await open(this.uploadPath(id),'r+')
-      try{
-        if(offset<u.offset){const old=Buffer.alloc(bytes.length);await file.read(old,0,old.length,offset);if(offset+bytes.length>u.offset||!old.equals(bytes))throw new Error('上传内容冲突');return {offset:u.offset}}
-        if(offset!==u.offset)throw new ComputerError('image_upload_offset','请从已接收的位置继续上传')
-        let written=0;while(written<bytes.length){const result=await file.write(bytes,written,bytes.length-written,offset+written);if(!result.bytesWritten)throw new Error('写入未完成');written+=result.bytesWritten}await file.sync();u.offset+=bytes.length
-        this.db.prepare('UPDATE managed_image_uploads SET value=? WHERE id=?').run(JSON.stringify(u),id);return {offset:u.offset}
-      }finally{await file.close()}
-    }finally{this.uploading.delete(id)}
-  }
-  async cancelUpload(owner:string,id:string){this.upload(owner,id);if(this.uploading.has(id)||this.pending)throw new ComputerError('image_upload_busy','请等待当前操作结束');await rm(this.uploadPath(id),{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(id);return {ok:true}}
-  async start(owner:string,id:string,operation:ImageOperation,args:{imageId?:string;uploadId?:string},check:()=>Promise<void>){
-    await this.ready;await check();z.string().uuid().parse(id)
-    const fingerprint=digest({owner,operation,args}),old=this.job(id)
-    if(old){if(old.fingerprint!==fingerprint)throw new ComputerError('idempotency_conflict','操作编号与内容不一致');return {job:old}}
-    if(this.closed||this.pending||this.blocked)throw new ComputerError('image_job_busy','已有镜像操作正在执行')
-    z.enum(['prepare','verify','activate','rollback','import','export']).parse(operation)
-    if(operation==='export'&&this.jobs().filter(j=>j.archiveSize).length>=3)throw new ComputerError('image_export_limit','请先清理之前的导出文件,最多保留三份')
-    const release=this.runtime.pool.beginMaintenance()
-    const job:StoredJob={id,owner,operation,fingerprint,state:'running',message:'正在准备',createdAt:Date.now(),updatedAt:Date.now()};this.save(job)
-    const progress=(message:string)=>{job.message=message;this.save(job)}
-    this.pending=(async()=>{
-      try{
-        await this.runtime.provider.verifyRuntime();await check()
-        const manager=this.images
-        const verify=async(manifest:any)=>{
-          progress('正在启动测试电脑并校验桌面');await check()
-          const spec=this.checkSpec(id,manifest.imageId)
-          try{await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('节点正在关闭')});const end=Date.now()+60000;for(;;){await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(e){if(Date.now()>end)throw e;await new Promise(r=>setTimeout(r,1000))}}}
-          finally{try{await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}catch{this.blocked={ids:[id],release};throw new ComputerError('computer_stop_uncertain','测试电脑停止尚未确认,请刷新状态重试清理')}}
-        }
-        const previousVerify=manager.verify.bind(manager);manager.verify=verify
-        try{
-          let manifest:any
-          if(operation==='prepare'){progress('正在构建兼容镜像,首次准备可能需要数分钟');manifest=await manager.prepare([join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(path=>existsSync(join(path,'Dockerfile')))??'missing-computer-recipe')}
-          else if(operation==='import'){
-            const u=this.upload(owner,args.uploadId!);if(u.offset!==u.size)throw new ComputerError('image_upload_incomplete','镜像尚未上传完成')
-            progress('正在校验归档并导入镜像');await writeFile(this.uploadPath(u.id)+'.json',JSON.stringify(u.manifest),{mode:0o600})
-            manifest=await manager.import(this.uploadPath(u.id));await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)
-          }else{
-            const selected=operation==='rollback'?this.selection()?.previous:args.imageId
-            if(!selected)throw new ComputerError('image_missing','没有可用的镜像或回退版本')
-            manifest=await manager.inspect(selected)
-            if(operation==='export'){progress('正在导出镜像');manifest=await manager.export(selected,join(this.directory,id+'.export.tar'));job.archiveSize=(await stat(join(this.directory,id+'.export.tar'))).size;job.manifest=manifest}
-            else await verify(manifest)
-          }
-          await check();job.imageId=manifest.imageId
-          if((operation==='activate'||operation==='rollback')&&manifest.imageId!==this.runtime.config.imageId){
-            const old=this.selection();this.db.prepare('INSERT OR REPLACE INTO managed_image_selection VALUES(1,?)').run(JSON.stringify({base:old?.base??this.runtime.config.imageId,active:manifest.imageId,previous:this.runtime.config.imageId}))
-            this.runtime.config.imageId=manifest.imageId
-          }
-          job.state='complete';progress(operation==='activate'||operation==='rollback'?'已应用,下次启动电脑使用此镜像':'操作完成')
-        }finally{manager.verify=previousVerify}
-      }catch(error){job.state=this.blocked?'uncertain':'failed';progress(error instanceof Error&&/^(镜像|电脑|上传|请|没有)/.test(error.message)?error.message.slice(0,240):'镜像操作失败,请检查运行时、网络和镜像清单后重试')}
-      finally{if(!this.blocked)release();this.pending=undefined}
-    })()
-    return {job:{...job}}
-  }
-  async download(owner:string,id:string,offset:number){
-    z.string().uuid().parse(id);z.number().int().nonnegative().parse(offset)
-    const job=this.job(id)
-    if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export'||!job.archiveSize||offset>job.archiveSize)throw new ComputerError('image_export_missing','导出文件不存在')
-    const file=await open(join(this.directory,id+'.export.tar'),'r');try{const bytes=Buffer.alloc(Math.min(chunkSize,job.archiveSize-offset));let received=0;while(received<bytes.length){const result=await file.read(bytes,received,bytes.length-received,offset+received);if(!result.bytesRead)throw new ComputerError('image_export_incomplete','镜像导出不完整');received+=result.bytesRead}return {data:bytes.toString('base64'),size:job.archiveSize,offset,manifest:job.manifest}}finally{await file.close()}
-  }
-  async removeExport(owner:string,id:string){const job=this.job(id);if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export')throw new ComputerError('image_export_missing','导出不存在');await rm(join(this.directory,id+'.export.tar'),{force:true});await rm(join(this.directory,id+'.export.tar.json'),{force:true});delete job.archiveSize;job.message='导出文件已清理,可重新导出';this.save(job);return {ok:true}}
-  async close(){this.closed=true;await this.ready;await this.pending;await this.retryCleanup()}
-}

+ 116 - 0
src/runner/computers/localVm.ts

@@ -0,0 +1,116 @@
+import { existsSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import type { DatabaseSync } from 'node:sqlite'
+import { ComputerImages } from './images.js'
+import { ComputerError } from './container.js'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '../../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
+import type { ComputerRuntime } from '../worker/gateway.js'
+
+type State = { imageId: string; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
+
+/** One managed Local VM image. It is prepared in Settings, never selected from an image catalog. */
+export class LocalVmImages {
+  readonly images: ComputerImages
+  readonly ready: Promise<void>
+  private state: State
+  private pending?: Promise<void>
+  private closed = false
+  private releaseMaintenance?: () => void
+  constructor(readonly runtime: ComputerRuntime, readonly db: DatabaseSync) {
+    this.images = new ComputerImages(runtime.config.runtime)
+    db.exec('CREATE TABLE IF NOT EXISTS local_vm_runtime(id INTEGER PRIMARY KEY CHECK(id=1),value TEXT NOT NULL)')
+    const row = db.prepare('SELECT value FROM local_vm_runtime WHERE id=1').get() as {value:string}|undefined
+    this.state = row ? JSON.parse(row.value) : {imageId:runtime.config.imageId,mode:'per-bot',maxInstances:runtime.config.maxConcurrent ?? 2}
+    // Preserve an already selected image when migrating; old archives and workspaces are untouched.
+    if (!row && db.prepare("SELECT name FROM sqlite_master WHERE name='managed_image_selection'").get()) {
+      const old = db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as {value:string}|undefined
+      if (old) this.state.imageId = JSON.parse(old.value).active ?? this.state.imageId
+    }
+    runtime.config.imageId = this.state.imageId
+    runtime.pool.limits.concurrent = this.state.maxInstances
+    runtime.retainDesktops = true
+    this.releaseMaintenance = runtime.pool.holdMaintenance()
+    this.ready = runtime.ready.then(async () => {
+      if (this.state.job?.state === 'running') {
+        try { await this.cleanup(this.state.job.id) }
+        catch {this.state.job={...this.state.job,state:'failed',message:'上次准备的测试虚拟机停止状态待核对,请启动容器运行环境后重新检查'};this.save();return}
+        this.state.job={...this.state.job,state:'failed',message:'上次准备已中断,可重新准备本地虚拟机'};this.save()
+      }
+      this.releaseMaintenance?.(); this.releaseMaintenance = undefined
+    })
+    void this.ready.catch(() => {})
+  }
+  private save() { this.db.prepare('INSERT OR REPLACE INTO local_vm_runtime VALUES(1,?)').run(JSON.stringify(this.state)) }
+  private spec(id:string,imageId=this.runtime.config.imageId) { return {id,ownerKey:'local-vm-prepare',imageId} }
+  private async cleanup(id:string) {
+    const spec = this.spec(id)
+    await this.runtime.provider.remove(spec)
+    await this.runtime.provider.deleteWorkspace(spec)
+  }
+  async status(owner?:string): Promise<LocalVmStatus> {
+    await this.ready
+    if(this.releaseMaintenance&&!this.pending&&this.state.job?.state==='failed') {
+      try{await this.cleanup(this.state.job.id);this.releaseMaintenance();this.releaseMaintenance=undefined}catch{}
+    }
+    let daemonUp=false,image=false,problem:string|undefined
+    try { await this.runtime.provider.verifyRuntime();daemonUp=true }
+    catch { problem='请先安装并启动 Docker 或 Podman' }
+    if(daemonUp && this.state.imageId!==UNCONFIGURED_COMPUTER_IMAGE) {
+      try { await this.images.inspect(this.state.imageId);image=true } catch { problem='本地虚拟机镜像尚未就绪,请重新准备' }
+    }
+    return {configured:true,runtime:this.runtime.config.runtime,daemonUp,image,imageId:image?this.state.imageId:undefined,
+      mode:this.state.mode,maxInstances:this.state.maxInstances,busy:!!this.pending||!!this.releaseMaintenance,problem,job:this.state.job,
+      instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
+  }
+  async policy(mode:LocalVmMode,maxInstances:number) {
+    await this.ready
+    const release=this.runtime.pool.beginMaintenance()
+    try { this.state.mode=mode;this.state.maxInstances=maxInstances;this.runtime.pool.limits.concurrent=maxInstances;this.save() }
+    finally { release() }
+    return this.status()
+  }
+  async prepare(id:string,check:()=>Promise<void>) {
+    await this.ready;await check()
+    if(this.state.job?.id===id)return this.status()
+    if(this.pending||this.closed)throw new ComputerError('local_vm_busy','本地虚拟机正在准备,请稍后查看')
+    this.releaseMaintenance=this.runtime.pool.beginMaintenance()
+    this.state.job={id,state:'running',message:'正在准备本地虚拟机'};this.save()
+    const progress=(message:string)=>{this.state.job!.message=message;this.save()}
+    this.pending=(async()=>{
+      try {
+        await this.runtime.provider.verifyRuntime();await check()
+        const verify=async(manifest:{imageId:string})=>{
+          progress('正在检查虚拟机桌面与电脑驱动')
+          const spec=this.spec(id,manifest.imageId)
+          try {
+            await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('服务已停止')})
+            const deadline=Date.now()+60000
+            for(;;) { await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(error){if(Date.now()>deadline)throw error;await new Promise(r=>setTimeout(r,1000))} }
+          } finally { await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec) }
+        }
+        this.images.verify=verify
+        const ids=(await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=cn.samien.yaoyao.computer-image=1'])).trim().split(/\s+/).filter(Boolean)
+        let image
+        for(const value of [this.state.imageId,...ids].filter(id=>id!==UNCONFIGURED_COMPUTER_IMAGE)) { try{image=await this.images.inspect(value);break}catch{} }
+        if(image)await verify(image)
+        else {
+          progress('正在准备托管桌面镜像,首次下载可能需要数分钟')
+          const recipe=[join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(p=>existsSync(join(p,'Dockerfile')))
+          if(!recipe)throw new Error('缺少本地虚拟机资源,请重新安装 App')
+          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()
+      } catch(error) {
+        this.state.job={id,state:'failed',message:error instanceof Error?error.message.slice(0,240):'本地虚拟机准备失败'};this.save()
+      } finally {
+        // Do not release maintenance when an interrupted verification VM may still be alive.
+        try { await this.cleanup(id);this.releaseMaintenance?.();this.releaseMaintenance=undefined } catch { this.state.job={id,state:'failed',message:'测试虚拟机停止状态待核对,请重启执行环境后重试'};this.save() }
+        this.pending=undefined
+      }
+    })()
+    return this.status()
+  }
+  async close() { this.closed=true;await this.ready;await this.pending }
+}

+ 37 - 6
src/runner/computers/pool.ts

@@ -4,7 +4,7 @@ import type {ComputerProvider,ComputerSpecification} from './container.js'
 import {ComputerError} from './container.js'
 import {ComputerError} from './container.js'
 
 
 export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
 export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
-type Status='free'|'preparing'|'active'|'stopping'|'uncertain'
+type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
 interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
 interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
 interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
 interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
 
 
@@ -60,9 +60,9 @@ export class ComputerPool {
       let entry=this.get(spec.id)
       let entry=this.get(spec.id)
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
       if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
       if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
-      if(entry&&entry.status!=='free')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
+      if(entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
       const rows=this.rows()
       const rows=this.rows()
-      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free').length>=this.limits.concurrent)throw new ComputerError('computer_quota','电脑资源已达上限,请等待其他任务释放')
+      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
       const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
       const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
       entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
       entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
       // This reservation is synchronous, before any provider await or next acquire.
       // This reservation is synchronous, before any provider await or next acquire.
@@ -85,7 +85,7 @@ export class ComputerPool {
       const entry=this.get(spec.id);if(!entry)return
       const entry=this.get(spec.id);if(!entry)return
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
       if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
-      if(entry.status!=='free')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
+      if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
       entry.status='stopping';entry.generation++;this.save(entry)
       entry.status='stopping';entry.generation++;this.save(entry)
       try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
       try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
       catch(error){entry.status='uncertain';this.save(entry);throw error}
       catch(error){entry.status='uncertain';this.save(entry);throw error}
@@ -115,18 +115,48 @@ export class ComputerPool {
     active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
     active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
     return pending
     return pending
   }
   }
-  release(lease:ComputerLease):Promise<void>{
+  release(lease:ComputerLease,keepRunning=false):Promise<void>{
+    if(keepRunning)return this.serial(lease.environmentId,async()=>{
+      this.assert(lease)
+      const entry=this.require(lease),active=this.active.get(lease.id)!
+      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
+      active.detach();active.controller.abort();this.active.delete(lease.id)
+      entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    })
     const existing=this.get(lease.environmentId)
     const existing=this.get(lease.environmentId)
     const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
     const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
     this.invalidate(entry)
     this.invalidate(entry)
     return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
     return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
   }
   }
   definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
   definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
+  async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    spec=this.provider.validateSpecification(spec)
+    await this.serial(spec.id,async()=>{
+      authorize()
+      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 任务并交还控制权')
+      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
+      entry??={spec,generation:0,status:'free',expiresAt:0,updatedAt:this.now()}
+      entry.status='preparing';entry.generation++;this.save(entry)
+      try {
+        if(['stop','recreate','remove'].includes(action)) {await this.provider.stop(entry.spec);await this.provider.remove(entry.spec)}
+        authorize()
+        if(starts){entry.spec=spec;await this.provider.ensure(spec,authorize);entry.status='idle'}else entry.status='free'
+        if(action==='remove')this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id)
+        else this.save(entry)
+      }catch(error){entry.status='uncertain';this.save(entry);throw error}
+    })
+  }
   async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
   async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
     return this.serial(id,async()=>{
     return this.serial(id,async()=>{
       const entry=this.get(id);if(!entry)return
       const entry=this.get(id);if(!entry)return
       if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
-      if(entry.status!=='free')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      if(entry.status==='idle')await this.provider.stop(entry.spec)
       await this.provider.remove(entry.spec)
       await this.provider.remove(entry.spec)
       this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
       this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
       return entry.spec
       return entry.spec
@@ -162,6 +192,7 @@ export class ComputerPool {
   async expire():Promise<void>{
   async expire():Promise<void>{
     for(const id of this.rows().map(entry=>entry.spec.id)){
     for(const id of this.rows().map(entry=>entry.spec.id)){
       const entry=this.get(id)!
       const entry=this.get(id)!
+      if(entry.status==='idle'&&this.now()-entry.updatedAt>=300000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
       if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
       if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
         this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
         this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
       }
       }

+ 16 - 6
src/runner/worker/control.ts

@@ -9,6 +9,7 @@ import type {ComputerControlStatus,ComputerFrame} from '../../shared/computerCon
 
 
 const actionSchema=z.discriminatedUnion('kind',[
 const actionSchema=z.discriminatedUnion('kind',[
   z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
   z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
+  z.object({kind:z.literal('drag'),fromX:z.number().int().nonnegative(),fromY:z.number().int().nonnegative(),toX:z.number().int().nonnegative(),toY:z.number().int().nonnegative()}).strict(),
   z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
   z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
   z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
   z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
   z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
   z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
@@ -36,7 +37,7 @@ export class ComputerControls {
     const state=this.state(meta)
     const state=this.state(meta)
     if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
     if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
     const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
     const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
-    return {mode:resource?.status==='active'?'agent':'off',generation:resource?.generation}
+    return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
   }
   }
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
     this.runtime.assertTarget(meta);authorize()
     this.runtime.assertTarget(meta);authorize()
@@ -84,16 +85,22 @@ export class ComputerControls {
     if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'操作编号与内容不一致','idempotency_conflict');return old.result}
     if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'操作编号与内容不一致','idempotency_conflict');return old.result}
     if(state.actions.size>=1000)throw new HttpError(429,'本次接管操作已达上限,请重新接管','computer_control_limit')
     if(state.actions.size>=1000)throw new HttpError(429,'本次接管操作已达上限,请重新接管','computer_control_limit')
     const common={scope:'desktop',session:`yaoyao-control-${state.id}`}
     const common={scope:'desktop',session:`yaoyao-control-${state.id}`}
-    let tool:string,args:Record<string,unknown>
+    let tool:string,args:Record<string,unknown>,argv:string[]|undefined
     if(action.kind==='click'){
     if(action.kind==='click'){
       if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
       if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
       tool='click';args={...common,x:action.x,y:action.y,button:action.button,count:action.count}
       tool='click';args={...common,x:action.x,y:action.y,button:action.button,count:action.count}
+    }else if(action.kind==='drag'){
+      if(action.fromX>=frame.width||action.toX>=frame.width||action.fromY>=frame.height||action.toY>=frame.height)throw new HttpError(400,'拖动位置超出画面','computer_point_invalid')
+      tool='drag';args={}
+      argv=['env','DISPLAY=:1','xdotool','mousemove','--sync',String(action.fromX),String(action.fromY),'mousedown','1','mousemove','--sync',String(action.toX),String(action.toY),'mouseup','1']
     }else if(action.kind==='text'){tool='type_text';args={...common,text:action.text}}
     }else if(action.kind==='text'){tool='type_text';args={...common,text:action.text}}
-    else if(action.kind==='key'){tool='press_key';args={...common,key:action.key,modifiers:action.modifiers}}
-    else {tool='scroll';args={...common,direction:action.direction,amount:action.amount}}
+    else if(action.kind==='key'){
+      const key=({Return:'enter',BackSpace:'backspace',Escape:'esc'} as Record<string,string>)[action.key]??action.key.toLowerCase()
+      tool=action.modifiers.length?'hotkey':'press_key';args=action.modifiers.length?{...common,keys:[...action.modifiers,key]}:{...common,key}
+    }else {tool='scroll';args={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
     const result=this.runtime.pool.use(state.lease,async context=>{
     const result=this.runtime.pool.use(state.lease,async context=>{
       this.guard(state);await state.check()
       this.guard(state);await state.check()
-      const response=await this.runtime.provider.execute(state.spec!,[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
+      const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
       this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
       this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
       if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
       if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
       return {ok:true,result:response.stdout}
       return {ok:true,result:response.stdout}
@@ -104,7 +111,10 @@ export class ComputerControls {
     const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
     const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
     await state.pending;this.guard(state);await state.check()
     await state.pending;this.guard(state);await state.check()
     if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
     if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
-    else await this.stop(state)
+    else if(this.runtime.retainDesktops&&state.lease){
+      await state.proxy?.close();await this.runtime.pool.release(state.lease,true)
+      state.valid=false;this.manual.delete(meta.environmentId)
+    }else await this.stop(state)
     return {ok:true}
     return {ok:true}
   }
   }
   private async stop(state:Manual){
   private async stop(state:Manual){

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

@@ -30,6 +30,7 @@ const TOOLS:WorkerTool[]=[
 ]
 ]
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
 const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
 export class ComputerRuntime {
 export class ComputerRuntime {
+  retainDesktops=false
   readonly controls:ComputerControls
   readonly controls:ComputerControls
   readonly provider:ContainerComputerProvider
   readonly provider:ContainerComputerProvider
   readonly pool:ComputerPool
   readonly pool:ComputerPool
@@ -106,7 +107,7 @@ export class ComputerGateway {
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       await this.authorized()
       await this.authorized()
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
-      if(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在电脑与环境的镜像页面准备并应用镜像','computer_image_required')
+      if(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       await this.authorized();this.guard()
       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:[]}
       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:[]}
@@ -240,12 +241,12 @@ export class ComputerGateway {
     const success=frame.completed!==false&&!frame.interrupted
     const success=frame.completed!==false&&!frame.interrupted
     if(success&&Array.isArray(frame.messages)){live.session.history=frame.messages;live.session.outcome='complete'}else live.session.outcome='failed'
     if(success&&Array.isArray(frame.messages)){live.session.history=frame.messages;live.session.outcome='complete'}else live.session.outcome='failed'
     this.runtime.save(live.session)
     this.runtime.save(live.session)
-    await this.release(live)
+    await this.release(live,success&&this.runtime.retainDesktops)
     live.running=false
     live.running=false
     if(this.closed||live.controller.signal.aborted)return
     if(this.closed||live.controller.signal.aborted)return
     this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
     this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
   }
   }
-  private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
+  private release(live:Live,keepRunning=false){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease,keepRunning)})();return live.releasing}
   private async lost(live:Live){
   private async lost(live:Live){
     if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
     if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()

+ 8 - 4
src/server/app.ts

@@ -49,7 +49,7 @@ import {
   HermesChatNotificationResolver,
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
-import {ImageManagementService} from './imageManagement.js'
+import {LocalVmService} from './localVm.js'
 import {SharedComputers} from './sharedComputers.js'
 import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
 import { RunnerHub } from './runnerHub.js'
@@ -95,6 +95,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
   chatCache?: ChatCacheCoordinator
+  localVm: LocalVmService
   close(): void
   close(): void
 }
 }
 
 
@@ -213,8 +214,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
-  const imageManagement=new ImageManagementService(workspace,auth,runners)
-  runners.imageAllowed=(id,runnerId)=>imageManagement.allowed(id,runnerId)
+  const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)
+  runners.localVmAllowed=(id,runnerId)=>localVm.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   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 workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
@@ -381,7 +382,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   })
 
 
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
-  const imageRouter=imageManagement.router();app.use(imageRouter.routes());app.use(imageRouter.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())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   app.use(runners.adminRouter().routes())
   app.use(runners.adminRouter().routes())
@@ -442,6 +443,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
 
   return {
   return {
     runners,
     runners,
+    localVm,
     workspace,
     workspace,
     workspaceRuntime,
     workspaceRuntime,
     realtime,
     realtime,
@@ -499,6 +501,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
       runtime.chatPushJobs.stop()
       runtime.chatPushJobs.stop()
     }
     }
   }
   }
+  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.workspaceRuntime.start()
   synchronizePushObservers()
   synchronizePushObservers()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
@@ -506,6 +509,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   return {
   return {
     server,
     server,
     close: async (closeConnections = false) => {
     close: async (closeConnections = false) => {
+      await runtime.localVm.close()
       removePushConfigurationListener()
       removePushConfigurationListener()
       removeWebSockets()
       removeWebSockets()
       runtime.realtime.close()
       runtime.realtime.close()

+ 2 - 2
src/server/computerControls.ts

@@ -32,9 +32,9 @@ export class ComputerControlService {
     const router=new Router()
     const router=new Router()
     router.get('/api/app/computers',ctx=>{
     router.get('/api/app/computers',ctx=>{
       const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
       const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
-      ctx.body={canManageImages:actor.role==='admin',computers:agents.map(agent=>{
+      ctx.body={canManageLocalVm:actor.role==='admin',computers:agents.map(agent=>{
         let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
         let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
-        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('image-management-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在镜像页面准备并应用镜像');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
+        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}
         return {agent,available,reason}
       })}
       })}
     })
     })

+ 0 - 37
src/server/imageManagement.ts

@@ -1,37 +0,0 @@
-import Router from '@koa/router'
-import {Readable} from 'node:stream'
-import {z} from 'zod'
-import type {WorkspaceStore} from './workspaceStore.js'
-import {parse} from './workspaceStore.js'
-import type {LocalAuthStore} from './localAuth.js'
-import type {RunnerHub} from './runnerHub.js'
-import {HttpError} from './errors.js'
-interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
-export class ImageManagementService {
-  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly hub:RunnerHub){}
-  allowed(id:string,runnerId:string){const grant=this.store.get<Grant>('_system','image-management-grant',id);return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version}
-  router(){
-    const router=new Router(),base='/api/app/admin/runners/:id/images'
-    const call=async(ctx:any,payload:Record<string,unknown>)=>{const owner=this.auth.requireAdmin(ctx).id;ctx.set('Cache-Control','no-store');return this.hub.images(owner,ctx.params.id,payload)}
-    router.get(base,async ctx=>{ctx.body=await call(ctx,{op:'status'})})
-    router.post(base+'/jobs',async ctx=>{
-      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid(),operation:z.enum(['prepare','verify','activate','rollback','import','export']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
-      const old=this.store.get<Grant>('_system','image-management-grant',body.requestId)
-      if(old&&(old.owner!==actor.id||old.runnerId!==ctx.params.id))throw new HttpError(409,'操作编号已使用','idempotency_conflict')
-      if(!old)this.store.put('_system','image-management-grant',body.requestId,{id:body.requestId,owner:actor.id,runnerId:ctx.params.id,version:this.auth.pushAuthorizationVersion(actor.id)??0,expiresAt:Date.now()+3600000})
-      ctx.body=await call(ctx,{op:'start',id:body.requestId,operation:body.operation,...(body.imageId?{imageId:body.imageId}:{}),...(body.uploadId?{uploadId:body.uploadId}:{})});ctx.status=202
-    })
-    router.post(base+'/uploads',async ctx=>{const body=parse(z.object({id:z.string().uuid(),size:z.number().int().positive().max(20*1024**3),manifest:z.record(z.string(),z.unknown())}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'upload',...body})})
-    router.post(base+'/uploads/:uploadId',async ctx=>{const body=parse(z.object({offset:z.number().int().nonnegative(),data:z.string().max(750000)}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'append',id:ctx.params.uploadId,...body})})
-    router.delete(base+'/uploads/:uploadId',async ctx=>{ctx.body=await call(ctx,{op:'cancel-upload',id:ctx.params.uploadId})})
-    router.delete(base+'/jobs/:jobId/archive',async ctx=>{ctx.body=await call(ctx,{op:'remove-export',id:ctx.params.jobId})})
-    router.get(base+'/jobs/:jobId/chunk',async ctx=>{ctx.body=await call(ctx,{op:'download',id:ctx.params.jobId,offset:Number(ctx.query.offset??0)})})
-    router.get(base+'/jobs/:jobId/manifest',async ctx=>{const data=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar.json');ctx.body=data.manifest})
-    router.get(base+'/jobs/:jobId/archive',async ctx=>{
-      const first=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar');ctx.type='application/octet-stream';ctx.length=first.size
-      const stream=Readable.from((async function*(){let part=first,offset=0;while(offset<first.size&&!ctx.res.destroyed){const bytes=Buffer.from(part.data,'base64');if(!bytes.length||part.offset!==offset)throw new Error('镜像导出数据不完整');yield bytes;offset+=bytes.length;if(offset<first.size)part=await call(ctx,{op:'download',id:ctx.params.jobId,offset})}})())
-      ctx.res.once('close',()=>stream.destroy());ctx.body=stream
-    })
-    return router
-  }
-}

+ 170 - 0
src/server/localVm.ts

@@ -0,0 +1,170 @@
+import Router from '@koa/router'
+import { z } from 'zod'
+import { existsSync, mkdirSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { RunnerAgent } from '../runner/agent.js'
+import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
+import type { WorkspaceAgent } from '../shared/workspace.js'
+import { parse, type WorkspaceStore } from './workspaceStore.js'
+import type { LocalAuthStore } from './localAuth.js'
+import type { WorkspaceNodes } from './workspaceGateway.js'
+import type { RunnerHub } from './runnerHub.js'
+import type { SharedComputers } from './sharedComputers.js'
+import type { ServerConfig } from './config.js'
+import { HttpError } from './errors.js'
+
+interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
+const exec=promisify(execFile)
+
+/** Settings owns preparation. Chats own each Agent's desktop and its lifecycle. */
+export class LocalVmService {
+  private url?:string
+  private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>}
+  private starting?:Promise<void>
+  private problem?:string
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
+    readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
+    store.prepareAgent=(owner,agent)=>{const record=this.record();if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)}
+  }
+  allowed(id:string,runnerId:string){
+    const grant=this.store.get<Grant>('_system','local-vm-grant',id)
+    return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
+  }
+  private mode(owner:string):LocalVmMode { return this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
+  private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
+  async start(url:string) {
+    this.url=url
+    const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
+    if(!stored||this.managed)return
+    const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
+    if(!this.hub.records().some(r=>r.id===config.runnerId&&r.enabled))return
+    config.serverURL=url
+    const home=join(this.config.home,'runner-state','local-vm',config.runnerId)
+    mkdirSync(home,{recursive:true,mode:0o700})
+    const abort=new AbortController(),agent=new RunnerAgent(config,home)
+    const state={agent,abort,done:Promise.resolve()}
+    this.managed=state
+    state.done=agent.run(abort.signal).catch(error=>{this.problem=error instanceof Error?error.message:'本地虚拟机服务已断开'}).finally(()=>{if(this.managed===state)this.managed=undefined})
+  }
+  private async discover(){
+    for(const runtime of ['docker','podman'] as const) {
+      try {
+        await exec(runtime,['--version'],{timeout:5000})
+        try { await exec(runtime,['info'],{timeout:8000,maxBuffer:1024*1024});return {runtime,daemonUp:true} }
+        catch{return {runtime,daemonUp:false}}
+      }catch{}
+    }
+    return {runtime:undefined,daemonUp:false}
+  }
+  private installation(){
+    const hermesHome=join(homedir(),'.hermes'),hermesSource=join(hermesHome,'hermes-agent')
+    return {hermesHome,hermesSource,python:join(hermesSource,'venv','bin','python')}
+  }
+  private async ensure(owner:string) {
+    if(this.starting)await this.starting
+    if(this.record()){
+      if(this.url&&!this.managed)await this.start(this.url)
+      return this.record()!
+    }
+    this.starting=(async()=>{
+      if(!this.url)throw new HttpError(503,'本机 Web 服务尚未启动','local_vm_unavailable')
+      const detected=await this.discover()
+      if(!detected.runtime||!detected.daemonUp)throw new HttpError(409,'请先安装并启动 Docker 或 Podman','local_vm_runtime_required')
+      const {hermesHome,hermesSource,python}=this.installation()
+      if(!existsSync(python)||!existsSync(join(hermesSource,'run_agent.py')))throw new HttpError(409,'没有找到本机 Hermes 运行环境。请先安装 Hermes,或在高级连接设置中配置执行节点','local_vm_hermes_required')
+      const sources=await this.nodes.sources(owner)
+      const allowedProfiles=[...new Set(sources.sources.filter(s=>s.nodeId==='local').map(s=>s.profile))]
+      const registered=this.hub.enroll(owner,{name:'本机虚拟机',sourceNodeId:'local',allowedProfiles})
+      const config:RunnerConfiguration={protocol:1,serverURL:this.url,runnerId:registered.runner.id,token:registered.token,
+        hermesURL:this.config.upstream.origin,allowedProfiles,artifactRoots:[],computers:{runtime:detected.runtime,
+          imageId:UNCONFIGURED_COMPUTER_IMAGE,python,hermesSource,hermesHome,maxConcurrent:2,network:'public-proxy'}}
+      this.store.put('_system','local-vm-managed','local',{sealed:this.nodes.seal(config)})
+      await this.start(this.url)
+    })().finally(()=>{this.starting=undefined})
+    await this.starting
+    const record=this.record()!
+    const deadline=Date.now()+8000
+    while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
+    return record
+  }
+  private grant(owner:string,id:string,runnerId:string){
+    const old=this.store.get<Grant>('_system','local-vm-grant',id)
+    if(old&&(old.owner!==owner||old.runnerId!==runnerId))throw new HttpError(409,'请求编号已用于其他操作','idempotency_conflict')
+    this.store.put('_system','local-vm-grant',id,{id,owner,runnerId,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+3600000})
+  }
+  async status(owner:string):Promise<LocalVmStatus> {
+    const record=this.record()
+    if(record){
+      if(this.url&&!this.managed)await this.start(this.url)
+      if(!this.hub.summary(record).online){
+        const detected=await this.discover()
+        return {configured:true,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem??'本机执行环境正在连接,请稍后重新检查'}
+      }
+      const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
+      return {...state,mode:this.mode(owner),instances:state.instances?.map(instance=>{
+        const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id&&a.execution==='computer')
+        return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
+      })}
+    }
+    const detected=await this.discover()
+    return {configured:false,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem}
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/admin/local-vm',async ctx=>{ctx.body=await this.status(this.auth.requireAdmin(ctx).id)})
+    router.post('/api/app/admin/local-vm/prepare',async ctx=>{
+      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId})
+    })
+    router.put('/api/app/admin/local-vm/policy',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({requestId:z.string().uuid(),mode:z.enum(['shared','per-bot']),maxInstances:z.number().int().min(1).max(4)}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
+      this.grant(owner,body.requestId,record.id)
+      await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
+      this.shared.setLocalVmMode(owner,body.mode,record.id)
+      this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
+      ctx.body=await this.status(owner)
+    })
+    router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,id=parse(z.string().uuid(),ctx.params.id),action=parse(z.enum(['stop','remove']),ctx.params.action)
+      const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body),record=this.record()
+      if(!record)throw new HttpError(409,'本地虚拟机尚未配置','local_vm_unavailable')
+      this.grant(owner,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(owner,record.id,{op:'instance',id:body.requestId,environmentId:id,action})
+    })
+    router.get('/api/app/agents/:id/local-vm',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      this.nodes.requireSource(owner,agent)
+      if(agent.execution!=='computer'){ctx.body={enabled:false,agent};return}
+      try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot'} }
+      catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用'}}
+    })
+    router.put('/api/app/agents/:id/local-vm',ctx=>{
+      const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean()}).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')
+      this.shared.assertLocalVmIdle(owner,[agent.id])
+      ctx.body=this.store.atomic(()=>{
+        if(!body.enabled)this.shared.detachLocalVm(owner,agent)
+        this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile'})
+        if(body.enabled&&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))}
+      })
+    })
+    router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
+      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.execution!=='computer'||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此 Agent 未使用本地虚拟机','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))
+    })
+    return router
+  }
+  async close(){this.managed?.abort.abort();await this.managed?.done}
+}

+ 24 - 24
src/server/runnerHub.ts

@@ -19,9 +19,9 @@ interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerpri
 
 
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
 export class RunnerHub {
-  get idleForUpdate(): boolean { return this.imageActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
-  private imageActivity = new Map<string, { owner: string; runnerId: string }>()
-  private imageChecks = new Set<string>()
+  get idleForUpdate(): boolean { return this.localVmActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
+  private localVmActivity = new Map<string, { owner: string; runnerId: string }>()
+  private localVmChecks = new Set<string>()
   controlAllowed:(id:string,runnerId:string)=>boolean=()=>false
   controlAllowed:(id:string,runnerId:string)=>boolean=()=>false
   readonly epoch=randomUUID()
   readonly epoch=randomUUID()
   private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
   private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
@@ -32,33 +32,33 @@ export class RunnerHub {
   private leases = new Map<string,Lease>()
   private leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
   private watchdog: ReturnType<typeof setInterval>
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
-    for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'image-management-grant'))
-      if (grant.expiresAt > Date.now()) this.imageActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
+    for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'local-vm-grant'))
+      if (grant.expiresAt > Date.now()) this.localVmActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
     this.watchdog=setInterval(()=>{
     this.watchdog=setInterval(()=>{
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
-      for (const [key, activity] of this.imageActivity) {
-        if (this.imageChecks.has(key) || !this.online.has(activity.runnerId)) continue
-        this.imageChecks.add(key)
-        void this.request(activity.runnerId, 'images.manage', { op: 'status', owner: hash(activity.owner) }, () => this.store.get<RunnerRecord>('_system', 'runner', activity.runnerId)?.enabled === true)
-          .then(result => { if (result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key) })
-          .catch(() => {}).finally(() => this.imageChecks.delete(key))
+      for (const [key, activity] of this.localVmActivity) {
+        if (this.localVmChecks.has(key) || !this.online.has(activity.runnerId)) continue
+        this.localVmChecks.add(key)
+        void this.request(activity.runnerId, 'local-vm.manage', { op: 'status', owner: hash(activity.owner) }, () => this.store.get<RunnerRecord>('_system', 'runner', activity.runnerId)?.enabled === true)
+          .then(result => { if (result.busy === false && this.localVmActivity.get(key) === activity) this.localVmActivity.delete(key) })
+          .catch(() => {}).finally(() => this.localVmChecks.delete(key))
       }
       }
     },5000);this.watchdog.unref()
     },5000);this.watchdog.unref()
   }
   }
-  imageAllowed:(id:string,runnerId:string)=>boolean=()=>false
-  async images(owner:string,id:string,payload:Record<string,unknown>){
+  localVmAllowed:(id:string,runnerId:string)=>boolean=()=>false
+  async localVm(owner:string,id:string,payload:Record<string,unknown>){
     const record=this.store.require<RunnerRecord>('_system','runner',id),version=this.auth.pushAuthorizationVersion(owner)
     const record=this.store.require<RunnerRecord>('_system','runner',id),version=this.auth.pushAuthorizationVersion(owner)
-    if(!record.enabled||!this.online.get(id)?.features.includes('image-management-v1'))throw new HttpError(409,'节点离线或尚不支持镜像管理,请更新并连接 Runner','image_management_unavailable')
+    if(!record.enabled||!this.online.get(id)?.features.includes('local-vm-v1'))throw new HttpError(409,'本地虚拟机执行环境离线或版本过旧,请重新连接或更新','local_vm_unavailable')
     const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
     const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
-    if(!valid())throw new HttpError(403,'镜像管理需要管理员权限','admin_required')
+    if(!valid())throw new HttpError(403,'本地虚拟机设置需要管理员权限','admin_required')
     const key = `${owner}:${id}`
     const key = `${owner}:${id}`
-    if (payload.op === 'start') this.imageActivity.set(key, { owner, runnerId: id })
-    const activity = this.imageActivity.get(key)
-    const result=await this.request(id,'images.manage',{...payload,owner:hash(owner)},valid)
-    if(!valid())throw new HttpError(403,'镜像管理授权已失效','admin_required')
-    if (payload.op === 'status' && result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key)
-    else if (payload.op === 'status' && result.busy === true && !activity) this.imageActivity.set(key, { owner, runnerId: id })
+    if (payload.op === 'prepare') this.localVmActivity.set(key, { owner, runnerId: id })
+    const activity = this.localVmActivity.get(key)
+    const result=await this.request(id,'local-vm.manage',{...payload,owner:hash(owner)},valid)
+    if(!valid())throw new HttpError(403,'本地虚拟机设置授权已失效','admin_required')
+    if (payload.op === 'status' && result.busy === false && this.localVmActivity.get(key) === activity) this.localVmActivity.delete(key)
+    else if (payload.op === 'status' && result.busy === true && !activity) this.localVmActivity.set(key, { owner, runnerId: id })
     return result
     return result
   }
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
   records():RunnerRecord[] {return this.store.list('_system','runner')}
@@ -197,7 +197,7 @@ export class RunnerHub {
   }
   }
   middleware():Koa.Middleware {
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
     return async(ctx,next)=>{
-      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|image-check)$/.exec(ctx.path)
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|local-vm-check)$/.exec(ctx.path)
       if(!match)return next()
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
@@ -214,7 +214,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','image-management-v1','image-ready-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-ready-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -235,7 +235,7 @@ export class RunnerHub {
       let body:any
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
-      if(match[2]==='image-check'){ctx.body={allowed:typeof body.id==='string'&&this.imageAllowed(body.id,record.id)};return}
+      if(match[2]==='local-vm-check'){ctx.body={allowed:typeof body.id==='string'&&this.localVmAllowed(body.id,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
       if(match[2]==='artifact'){
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)

+ 37 - 1
src/server/sharedComputers.ts

@@ -8,7 +8,7 @@ import {HttpError} from './errors.js'
 import type {WorkspaceNodes} from './workspaceGateway.js'
 import type {WorkspaceNodes} from './workspaceGateway.js'
 import type {RunnerHub} from './runnerHub.js'
 import type {RunnerHub} from './runnerHub.js'
 import type {LocalAuthStore} from './localAuth.js'
 import type {LocalAuthStore} from './localAuth.js'
-export interface SharedComputer {id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
+export interface SharedComputer {managedLocalVm?:boolean;id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
 export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
 export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
   if(!agent.computerEnvironmentId)return
   if(!agent.computerEnvironmentId)return
   const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
   const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
@@ -20,6 +20,42 @@ export class SharedComputers {
     if(this.store.list<{agentId:string;status:string}>(owner,'turn').some(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止这些成员的当前任务,再修改电脑共享','shared_computer_busy')
     if(this.store.list<{agentId:string;status:string}>(owner,'turn').some(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止这些成员的当前任务,再修改电脑共享','shared_computer_busy')
     if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(environmentId&&grant.environmentId===environmentId))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
     if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(environmentId&&grant.environmentId===environmentId))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
   }
   }
+  assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local').map(a=>a.id))}
+  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}
+    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
+  }
+  detachLocalVm(owner:string,agent:WorkspaceAgent){
+    if(!agent.computerEnvironmentId)return
+    const group=this.store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
+    group.memberIds=group.memberIds.filter(id=>id!==agent.id);group.archived=group.memberIds.length===0
+    this.store.put(owner,'shared-computer',group.id,group)
+    delete agent.computerEnvironmentId;delete agent.computerEnvironmentName
+    this.store.put(owner,'agent',agent.id,agent)
+  }
+  setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string){
+    this.assertLocalVmIdle(owner)
+    this.store.atomic(()=>{
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId)
+      const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&g.runnerId===runnerId)
+      for(const group of groups){group.archived=true;this.store.put(owner,'shared-computer',group.id,group)}
+      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}
+      }
+      for(const agent of agents){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
+    })
+  }
   create(owner:string,input:unknown){
   create(owner:string,input:unknown){
     const body=parse(z.object({requestId:z.string().uuid(),name:z.string().trim().min(1).max(100),memberIds:z.array(z.string().uuid()).min(2).max(8),trusted:z.literal(true)}).strict(),input)
     const body=parse(z.object({requestId:z.string().uuid(),name:z.string().trim().min(1).max(100),memberIds:z.array(z.string().uuid()).min(2).max(8),trusted:z.literal(true)}).strict(),input)
     return this.store.command(owner,body.requestId,{operation:'computer.share',...body},()=>this.store.atomic(()=>{
     return this.store.command(owner,body.requestId,{operation:'computer.share',...body},()=>this.store.atomic(()=>{

+ 2 - 0
src/server/workspaceStore.ts

@@ -100,6 +100,7 @@ export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
   return parsed.data
   return parsed.data
 }
 }
 export class WorkspaceStore {
 export class WorkspaceStore {
+  prepareAgent?: (owner:string,agent:Agent)=>void
   private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
   private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
   observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
   observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
     this.observers.add(listener)
     this.observers.add(listener)
@@ -311,6 +312,7 @@ export class WorkspaceStore {
         createdAt: now,
         createdAt: now,
         updatedAt: now,
         updatedAt: now,
       }
       }
+      this.prepareAgent?.(owner,agent)
       this.put(owner, 'agent', id, agent)
       this.put(owner, 'agent', id, agent)
       if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
       if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
       const conversation: Conversation = {
       const conversation: Conversation = {

+ 2 - 1
src/shared/computerControl.ts

@@ -1,7 +1,8 @@
 export interface ComputerFrame {id:string;generation:number;data:string;width:number;height:number;capturedAt:number}
 export interface ComputerFrame {id:string;generation:number;data:string;width:number;height:number;capturedAt:number}
-export interface ComputerControlStatus {mode:'off'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
+export interface ComputerControlStatus {mode:'off'|'idle'|'agent'|'pausing'|'human'|'resuming'|'error';generation?:number;controlId?:string;canResume?:boolean;error?:string}
 export type ComputerInput =
 export type ComputerInput =
   | {kind:'click';x:number;y:number;button?:'left'|'right'|'middle';count?:number}
   | {kind:'click';x:number;y:number;button?:'left'|'right'|'middle';count?:number}
+  | {kind:'drag';fromX:number;fromY:number;toX:number;toY:number}
   | {kind:'text';text:string}
   | {kind:'text';text:string}
   | {kind:'key';key:string;modifiers?:string[]}
   | {kind:'key';key:string;modifiers?:string[]}
   | {kind:'scroll';direction:'up'|'down'|'left'|'right';amount?:number}
   | {kind:'scroll';direction:'up'|'down'|'left'|'right';amount?:number}

+ 0 - 3
src/shared/imageManagement.ts

@@ -1,3 +0,0 @@
-export type ImageOperation='prepare'|'verify'|'activate'|'rollback'|'import'|'export'
-export interface ImageJob {id:string;operation:ImageOperation;state:'running'|'complete'|'failed'|'uncertain';message:string;imageId?:string;createdAt:number;updatedAt:number;archiveSize?:number;manifest?:Record<string,unknown>}
-export interface ImageInventory {configured:boolean;activeImageId?:string;previousImageId?:string;images:Array<{imageId:string;architecture:string;driver:string}>;jobs:ImageJob[];busy:boolean;uploads?:Array<{id:string;size:number;offset:number}>}

+ 24 - 0
src/shared/localVm.ts

@@ -0,0 +1,24 @@
+export type LocalVmMode = 'shared' | 'per-bot'
+export type LocalVmAction = 'create' | 'start' | 'stop' | 'recreate' | 'remove'
+export interface LocalVmStatus {
+  configured: boolean
+  runtime?: 'docker' | 'podman'
+  daemonUp: boolean
+  image: boolean
+  imageId?: string
+  mode: LocalVmMode
+  maxInstances: number
+  busy: boolean
+  problem?: string
+  job?: { id: string; state: 'running' | 'complete' | 'failed'; message: string }
+  instances?: Array<{id:string;status:string;name?:string;orphaned?:boolean}>
+}
+export interface LocalVmInstance {
+  container: 'missing' | 'stopped' | 'running'
+  ready: boolean
+  inUse: boolean
+  mode: LocalVmMode
+  maxInstances: number
+  image: boolean
+  problem?: string
+}

+ 1 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 }
 export interface RunnerCommand {
 export interface RunnerCommand {
   id: string
   id: string
-  kind: 'images.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
   payload: Record<string, unknown>
   payload: Record<string, unknown>
   expiresAt: number
   expiresAt: number
 }
 }

+ 86 - 96
tests/e2e/team-tools.spec.ts

@@ -113,106 +113,96 @@ test('registers an execution node, downloads its private config and disables it'
   expect((await (await page.request.get('/api/app/admin/runners')).json()).runners.find((r:any)=>r.id===configuration.runnerId).enabled).toBe(false)
   expect((await (await page.request.get('/api/app/admin/runners')).json()).runners.find((r:any)=>r.id===configuration.runnerId).enabled).toBe(false)
 })
 })
 
 
-test('explains isolated execution and keeps the edit form intact when a computer Runner is missing',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await page.locator('.desktop-sidebar .sidebar-create-trigger').click()
-  await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
-  const dialog=page.getByRole('dialog')
-  await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器隔离成员')
-  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).selectOption('computer')
-  await expect(dialog).toContainText('联网范围由执行节点的配置决定')
-  await dialog.getByRole('button',{name:'保存',exact:true}).click()
-  await expect(dialog.getByText(/隔离电脑需要连接执行节点/)).toBeVisible()
-  await expect(dialog).toBeVisible()
-  await expect(dialog.getByRole('combobox',{name:'执行环境',exact:true})).toHaveValue('computer')
-  await page.setViewportSize({width:390,height:844})
-  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('computer-execution-mobile.png')})
-})
-
-test('computer viewing is read-only until takeover and returns control with a user note',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
-  const seeded=await page.request.post('/__test/computer',{data:{}});expect(seeded.ok(),await seeded.text()).toBe(true)
-  const fixture=await seeded.json();expect(fixture.conversationId).toBeTruthy()
-  await page.goto(`/conversations/${fixture.conversationId}`)
-  await page.getByRole('button',{name:'电脑',exact:true}).click()
-  const panel=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=panel.getByRole('img',{name:'隔离电脑当前画面'})
-  await expect(screen).toBeVisible()
-  await screen.click()
-  expect((await (await page.request.get('/__test/computer')).json()).actions).toEqual([])
-  await panel.getByRole('button',{name:'接管电脑',exact:true}).click()
-  await expect(panel).toContainText('人工控制中')
-  await panel.getByRole('textbox',{name:'输入到电脑',exact:true}).fill('人工输入验收')
-  await panel.getByRole('button',{name:'输入文字',exact:true}).click()
-  await expect.poll(async()=>(await (await page.request.get('/__test/computer')).json()).actions).toEqual([{kind:'text',text:'人工输入验收'}])
-  await panel.getByRole('textbox',{name:'交还说明',exact:true}).fill('已完成设置,请继续任务')
-  await page.setViewportSize({width:390,height:844})
-  await panel.getByRole('button',{name:'交还 Agent 并继续',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('computer-panel-mobile.png')})
-  await panel.getByRole('button',{name:'交还 Agent 并继续',exact:true}).click()
-  await expect(panel.getByRole('button',{name:'接管电脑',exact:true})).toBeVisible()
-  await panel.getByRole('button',{name:'关闭',exact:true}).click()
-  await expect(panel).not.toBeVisible()
+test('configures the Agent computer in its chat panel and keeps preparation in app settings',async({page},testInfo)=>{
+ await page.goto('/conversations')
+ await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-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('尚未配置的本地电脑')
+ await expect(editor.getByRole('combobox',{name:'执行环境'})).toHaveCount(0)
+ await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
+ await expect(dock.getByRole('button',{name:'设置本地虚拟机',exact:true})).toBeVisible()
+ await page.screenshot({path:testInfo.outputPath('local-vm-unconfigured-agent.png')})
 })
 })
 
 
+async function login(page:any,ready=false){
+ 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 page.request.post('/__test/local-vm'+(ready?'?ready=1':''),{data:{}})
+}
 
 
-test('trusted shared computers require an explicit data-sharing grant and can be detached',async({page},testInfo)=>{
-  await page.goto('/conversations')
-  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
-  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
-  await page.getByRole('button',{name:'登录',exact:true}).click()
-  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
-  const seeded=await page.request.post('/__test/computer?shared=1',{data:{}});expect(seeded.ok(),await seeded.text()).toBe(true)
-  const fixture=await seeded.json();await page.goto(`/conversations/${fixture.conversationId}`)
-  await page.getByRole('button',{name:'电脑',exact:true}).click()
-  const panel=page.getByRole('dialog',{name:'隔离电脑',exact:true})
-  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
-  const create=panel.getByRole('button',{name:'创建可信共享电脑',exact:true})
-  await expect(create).toBeDisabled()
-  await panel.getByRole('checkbox',{name:'我确认这些成员可以访问同一工作区和浏览器登录资料'}).check()
-  await create.click()
-  await expect(panel).toContainText('共享电脑:团队共享电脑')
-  await page.setViewportSize({width:390,height:844})
-  await panel.getByRole('button',{name:'电脑共享设置',exact:true}).click()
-  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).scrollIntoViewIfNeeded()
-  await page.screenshot({path:testInfo.outputPath('shared-computer-mobile.png')})
-  await panel.getByRole('button',{name:'解除这台电脑的共享',exact:true}).click()
-  await expect(panel).not.toContainText('共享电脑:团队共享电脑')
-  const agents=(await (await page.request.get('/api/app/agents')).json()).agents
-  expect(agents.find((agent:any)=>agent.id===fixture.agentId).computerEnvironmentId).toBeUndefined()
+test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{
+ await login(page)
+ await expect(page.getByRole('button',{name:'电脑与环境',exact:true})).toHaveCount(0)
+ await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+ await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'设置中心'})
+ await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ const panel=settings.getByRole('region',{name:'本地虚拟机设置'})
+ await expect(panel).toContainText('隔离方式')
+ await expect(panel.getByRole('button',{name:'上传并导入'})).toHaveCount(0)
+ await panel.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+ await expect(panel).toContainText('虚拟机环境已就绪')
+ await panel.getByRole('combobox',{name:'虚拟机数量上限'}).selectOption('3')
+ await expect(panel.getByRole('combobox',{name:'虚拟机数量上限'})).toHaveValue('3')
+ await panel.getByRole('heading',{name:'本地虚拟机',exact:true}).scrollIntoViewIfNeeded()
+ await page.screenshot({path:testInfo.outputPath('local-vm-settings.png')})
+ expect((await page.request.get('/api/app/admin/runners/11111111-1111-4111-8111-111111111111/images')).status()).toBe(404)
 })
 })
 
 
+test('Agent computer appears as a right panel and opens an interactive desktop',async({page},testInfo)=>{
+ await login(page)
+ const seed=await(await page.request.post('/__test/computer?off=1',{data:{}})).json()
+ await page.goto(`/conversations/${seed.conversationId}`)
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await expect(dock).toBeVisible();await expect(dock).toContainText('本地虚拟机')
+ await dock.getByRole('button',{name:'设置本地虚拟机',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'设置中心'})
+ await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+ await settings.getByRole('button',{name:'关闭设置中心',exact:true}).click()
+ await expect(dock.getByRole('button',{name:/创建.*的虚拟机/})).toBeVisible({timeout:12000})
+ await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
+ await expect(dock.getByRole('img')).toBeVisible()
+ const bounds=await dock.boundingBox();expect(bounds!.x).toBeGreaterThan(500)
+ await page.screenshot({path:testInfo.outputPath('agent-local-vm-panel.png')})
+ expect((await(await page.request.get('/__test/computer')).json()).actions).toEqual([])
+ await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
+ const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true})
+ await expect(viewer).toContainText('人工控制中')
+ await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click()
+ await viewer.getByRole('textbox',{name:'输入到电脑',exact:true}).fill('本地虚拟机交互验收')
+ await viewer.getByRole('button',{name:'输入文字',exact:true}).click()
+ await expect.poll(async()=>(await(await page.request.get('/__test/computer')).json()).actions).toEqual([{kind:'text',text:'本地虚拟机交互验收'}])
+ await viewer.getByRole('img').dblclick()
+ await expect.poll(async()=>(await(await page.request.get('/__test/computer')).json()).actions.filter((a:any)=>a.kind==='click')).toMatchObject([{kind:'click',button:'left',count:2}])
+ await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
+ await expect(dock.getByRole('button',{name:'启动虚拟机',exact:true})).toBeVisible()
+})
 
 
-test('always exposes computer management and performs image and sharing actions through the UI',async({page},testInfo)=>{
- await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
- await expect(page.locator('.desktop-sidebar .environment-entry')).toBeVisible()
- await page.request.post('/__test/image-management',{data:{}})
- await page.locator('.desktop-sidebar .environment-entry button').click()
- const hub=page.getByRole('dialog',{name:'电脑与环境',exact:true})
- await expect(hub).toBeVisible();await expect(hub.getByText('尚未配置电脑',{exact:true}).first()).toBeVisible()
- await hub.getByRole('button',{name:'镜像',exact:true}).click()
- await expect(hub.getByRole('button',{name:'准备兼容镜像'})).toBeVisible()
- await expect(hub.getByRole('region',{name:'桌面镜像状态'})).toContainText('桌面镜像已应用')
- await page.screenshot({path:testInfo.outputPath('image-desktop-overview.png')})
- const rows=hub.locator('.image-card');await expect(rows.first()).toContainText('当前使用')
- await rows.nth(1).getByRole('button',{name:'应用',exact:true}).click()
- await expect(rows.nth(1)).toContainText('当前使用')
- await hub.getByRole('button',{name:'回退到上一镜像'}).click();await expect(rows.first()).toContainText('当前使用')
- await rows.first().getByRole('button',{name:'导出',exact:true}).click()
- await expect(hub.getByRole('link',{name:'下载镜像',exact:true})).toBeVisible()
- const download=page.waitForEvent('download');await hub.getByRole('link',{name:'下载镜像',exact:true}).click();expect((await download).suggestedFilename()).toBe('computer-image.tar')
- await page.setViewportSize({width:375,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await hub.getByRole('button',{name:'准备兼容镜像'}).scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('environment-images-mobile.png')})
- await hub.getByRole('button',{name:'共享环境',exact:true}).click();await expect(hub.getByText('创建共享环境',{exact:true}).first()).toBeVisible()
- await hub.getByRole('button',{name:'关闭',exact:true}).click()
- await page.route('**/api/app/computers',route=>route.fulfill({status:404,contentType:'application/json',body:'{"error":"old server"}'}))
- await page.setViewportSize({width:1280,height:900});await page.locator('.desktop-sidebar .environment-entry button').click()
- await expect(page.getByRole('dialog',{name:'电脑与环境'})).toContainText('当前连接的服务尚未提供电脑管理')
- await page.screenshot({path:testInfo.outputPath('environment-old-server.png')})
+test('two desktops are watch-only on opening and release before changing the controlled pane',async({page},testInfo)=>{
+ await login(page,true)
+ const first=await(await page.request.post('/__test/computer',{data:{}})).json()
+ const second=await(await page.request.post('/__test/computer',{data:{}})).json()
+ await page.goto(`/conversations/${first.conversationId}`)
+ await page.getByRole('button',{name:'电脑',exact:true}).click()
+ const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+ await expect(dock.getByRole('button',{name:'打开双桌面'})).toBeVisible()
+ await dock.getByRole('button',{name:'打开双桌面'}).click()
+ const workspace=page.getByRole('main',{name:'双桌面工作区'})
+ await expect(workspace.locator('.pane')).toHaveCount(2)
+ await workspace.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.agentId)
+ await expect(workspace.locator('.pane').last().getByRole('img')).toBeVisible()
+ await expect(workspace.getByRole('button',{name:'接管电脑',exact:true})).toHaveCount(2)
+ await workspace.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
+ await expect(workspace.locator('.pane').first()).toContainText('人工控制中')
+ await workspace.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
+ await expect(workspace.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true})).toBeVisible()
+ await expect(workspace.locator('.pane').last()).toContainText('人工控制中')
+ await page.screenshot({path:testInfo.outputPath('local-vm-two-desktops.png')})
+ await workspace.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ await expect(workspace).toHaveCount(0)
 })
 })

+ 15 - 21
tests/fixtures/workspace-server.ts

@@ -356,7 +356,9 @@ runtime.runners.computerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id
 runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
 runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
   const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
   const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
   authorize()
   authorize()
-  if(op==='frame')return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:512,height:512,capturedAt:Date.now(),data:readFileSync(resolve('public/icons/icon-512.png')).toString('base64')}
+  if(op==='detail')return {enabled:agent.execution==='computer',container:state.mode==='off'?'missing':state.mode==='stopped'?'stopped':'running',ready:!['off','stopped'].includes(state.mode),inUse:state.mode==='human',controlMode:state.mode,image:vmFixture.image,mode:agent.computerEnvironmentId?'shared':'per-bot',maxInstances:vmFixture.maxInstances}
+  if(op==='lifecycle'){state.mode=data.action==='remove'?'off':data.action==='stop'?'stopped':'idle';state.generation++;return {ok:true}}
+  if(op==='frame'){const png=readFileSync(process.env.YAOYAO_VM_PREVIEW_PNG||resolve('public/icons/icon-512.png'));return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:png.readUInt32BE(16),height:png.readUInt32BE(20),capturedAt:Date.now(),data:png.toString('base64')}}
   if(op==='take'){state.controlId=String(data.controlId);state.mode='human';state.generation++}
   if(op==='take'){state.controlId=String(data.controlId);state.mode='human';state.generation++}
   if(op==='input')state.actions.push(data.action)
   if(op==='input')state.actions.push(data.action)
   if(op==='giveback'){state.mode='agent';state.controlId=undefined;state.generation++}
   if(op==='giveback'){state.mode='agent';state.controlId=undefined;state.generation++}
@@ -366,8 +368,8 @@ runtime.app.use((ctx,next)=>{
   if(ctx.path!=='/__test/computer')return next()
   if(ctx.path!=='/__test/computer')return next()
   const owner=auth.require(ctx).id
   const owner=auth.require(ctx).id
   if(ctx.method==='POST'){
   if(ctx.method==='POST'){
-    const agent=runtime.workspace.createAgent(owner,{name:ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收',profile:'default',execution:'computer'})
-    computerFixtures.set(agent.id,{mode:'agent',generation:1,actions:[]})
+    const agent=runtime.workspace.createAgent(owner,{name:(ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收')+' '+(computerFixtures.size+1),profile:'default',execution:'computer'})
+    computerFixtures.set(agent.id,{mode:ctx.query.off==='1'?'off':'idle',generation:1,actions:[]})
     if(ctx.query.shared==='1'){
     if(ctx.query.shared==='1'){
       const second=runtime.workspace.createAgent(owner,{name:'可信共享成员',profile:'default',execution:'computer'})
       const second=runtime.workspace.createAgent(owner,{name:'可信共享成员',profile:'default',execution:'computer'})
       computerFixtures.set(second.id,{mode:'off',generation:1,actions:[]})
       computerFixtures.set(second.id,{mode:'off',generation:1,actions:[]})
@@ -378,25 +380,17 @@ runtime.app.use((ctx,next)=>{
   }
   }
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
 })
 })
-const imageFixture={activeImageId:'sha256:'+'a'.repeat(64),previousImageId:undefined as string|undefined,images:[{imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0'},{imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0'}],jobs:[] as any[],uploads:[] as any[]}
-let imageRunnerId=''
-const originalSummary=runtime.runners.summary.bind(runtime.runners),originalImages=runtime.runners.images.bind(runtime.runners)
-runtime.runners.summary=record=>record.id===imageRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','image-management-v1','image-ready-v1']}:originalSummary(record)
-runtime.runners.images=async(owner,id,p)=>{
- if(id!==imageRunnerId)return originalImages(owner,id,p)
- if(p.op==='status')return {configured:true,busy:false,...imageFixture}
- if(p.op==='start'){
-  if(p.operation==='activate'||p.operation==='rollback'){const old=imageFixture.activeImageId;imageFixture.activeImageId=String(p.operation==='rollback'?imageFixture.previousImageId:p.imageId);imageFixture.previousImageId=old}
-  const job={id:p.id,operation:p.operation,state:'complete',message:'操作完成',createdAt:Date.now(),updatedAt:Date.now(),...(p.operation==='export'?{archiveSize:7,manifest:{protocol:1}}:{})};imageFixture.jobs.unshift(job);return {job}
- }
- if(p.op==='download')return {data:Buffer.from('fixture').toString('base64'),offset:0,size:7,manifest:{protocol:1}}
- if(p.op==='upload'){imageFixture.uploads.push({id:p.id,size:p.size,offset:0});return {id:p.id,offset:0}}
- if(p.op==='append'){const upload=imageFixture.uploads.find(u=>u.id===p.id);upload.offset+=Buffer.from(String(p.data),'base64').length;return {offset:upload.offset}}
- if(p.op==='remove-export'){delete imageFixture.jobs.find(j=>j.id===p.id).archiveSize;return {ok:true}}
- if(p.op==='cancel-upload'){imageFixture.uploads=imageFixture.uploads.filter(u=>u.id!==p.id);return {ok:true}}
- return {ok:true}
+const vmFixture={configured:true,runtime:'docker',daemonUp:true,image:false,mode:'per-bot',maxInstances:2,busy:false,job:undefined as any}
+let vmRunnerId=''
+const originalSummary=runtime.runners.summary.bind(runtime.runners),originalLocalVm=runtime.runners.localVm.bind(runtime.runners)
+runtime.runners.summary=record=>record.id===vmRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1']}:originalSummary(record)
+runtime.runners.localVm=async(owner,id,p)=>{
+ if(id!==vmRunnerId)return originalLocalVm(owner,id,p)
+ if(p.op==='prepare'){vmFixture.image=true;vmFixture.job={id:p.id,state:'complete',message:'本地虚拟机已就绪'}}
+ if(p.op==='policy'){vmFixture.mode=String(p.mode);vmFixture.maxInstances=Number(p.maxInstances)}
+ return {...vmFixture}
 }
 }
-runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/image-management')return next();const owner=auth.requireAdmin(ctx).id;imageRunnerId=runtime.runners.enroll(owner,{name:'镜像管理验收节点',allowedProfiles:['default']}).runner.id;ctx.body={runnerId:imageRunnerId}})
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {
   if (!ctx.path.startsWith('/api/')) {

+ 117 - 0
tests/live/localVm.live.test.ts

@@ -0,0 +1,117 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
+import {readFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:http'
+import {chromium} from '@playwright/test'
+import serve from 'koa-static'
+import request from 'supertest'
+import {createApplication,createNodeServer} from '../../src/server/app'
+import {loadServerConfig} from '../../src/server/config'
+
+it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('prepares the local runner automatically and operates a real desktop from the Agent chat panel',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-ui-')),hermes=join(root,'hermes'),evidence=resolve(process.env.YAOYAO_VM_EVIDENCE??'test-results/local-vm-live')
+ await mkdir(hermes)
+ const upstream=createServer((req,res)=>{
+  res.setHeader('Content-Type','application/json')
+  const path=new URL(req.url!,'http://fixture').pathname
+  res.end(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'}]}:path==='/api/config'?{terminal:{cwd:'/projects/local-vm'}}:{ok:true}))
+ })
+ await new Promise<void>(done=>upstream.listen(0,'127.0.0.1',done))
+ const upstreamURL=`http://127.0.0.1:${(upstream.address() as any).port}`
+ await writeFile(join(hermes,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: ${upstreamURL}/v1\n  api_key: fixture\nterminal:\n  cwd: /projects/local-vm\n`,{mode:0o600})
+ const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:join(root,'web'),HERMES_YAOYAO_UPSTREAM:upstreamURL})})
+ vi.spyOn(app.localVm as any,'installation').mockReturnValue({hermesHome:hermes,hermesSource:process.env.YAOYAO_HERMES_SOURCE,python:process.env.YAOYAO_HERMES_PYTHON})
+ app.app.use(serve(resolve('dist')))
+ app.app.use(ctx=>{if(!ctx.path.startsWith('/api/')){ctx.type='html';ctx.body=readFileSync(resolve('dist/index.html'))}})
+ const node=createNodeServer(app)
+ await new Promise<void>(done=>node.server.listen(0,'127.0.0.1',done))
+ app.config.port=(node.server.address() as any).port
+ const address=`http://127.0.0.1:${app.config.port}`
+ let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
+ try{
+  const bootstrap=await request(address).get('/api/app/bootstrap').expect(200)
+  const setup=await request(address).post('/api/app/setup').set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'vm-ui-fixture',password:'vm-ui-fixture-password'}).expect(200)
+  const owner=setup.body.user.id
+  const agent=app.workspace.createAgent(owner,{name:'本地虚拟机验收',profile:'default'})
+  const conversation=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
+  browser=await chromium.launch({headless:true})
+  const context=await browser.newContext({viewport:{width:1440,height:960},locale:'zh-CN'})
+  const page=await context.newPage()
+  const inputs:unknown[]=[],failures:unknown[]=[]
+  page.on('request',r=>{if(r.url().endsWith('/computer/input'))inputs.push(r.postDataJSON()?.action)})
+  page.on('response',r=>{if(r.url().includes('/computer')&&r.status()>=400)void r.json().then(value=>failures.push(value))})
+  await page.goto(address+'/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+  await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+  const settings=page.getByRole('dialog',{name:'设置中心'})
+  await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+  await settings.getByRole('button',{name:'准备本地虚拟机',exact:true}).click()
+  await expect.poll(async()=>await settings.innerText(),{timeout:120000}).toContain('虚拟机环境已就绪')
+  expect(app.runners.records()).toHaveLength(1)
+  expect(app.runners.records()[0]?.name).toBe('本机虚拟机')
+  await mkdir(evidence,{recursive:true})
+  await page.screenshot({path:join(evidence,'settings.png')})
+  await settings.getByRole('button',{name:'关闭设置中心'}).click()
+  await page.goto(address+`/conversations/${conversation.id}`)
+  await page.getByRole('button',{name:'电脑',exact:true}).click()
+  const dock=page.getByRole('complementary',{name:'Agent 电脑面板'})
+  await dock.getByRole('combobox',{name:'此 Agent 使用的电脑'}).selectOption('vm')
+  await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
+  await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
+  await page.screenshot({path:join(evidence,'agent-panel.png')})
+  await dock.getByRole('button',{name:'打开桌面',exact:true}).click()
+  const viewer=page.getByRole('dialog',{name:'隔离电脑',exact:true}),screen=viewer.getByRole('img')
+  await expect.poll(async()=>await viewer.innerText(),{timeout:30000}).toContain('人工控制中')
+  await expect.poll(()=>viewer.locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
+  await expect.poll(()=>screen.evaluate(e=>(e as HTMLImageElement).naturalWidth),{timeout:10000}).toBeGreaterThan(1000)
+  const box=(await screen.boundingBox())!
+  await screen.click({position:{x:box.width*568/1280,y:box.height*875/900}})
+  await page.waitForTimeout(1500)
+  await screen.focus()
+  await page.keyboard.type('printf vm-ui-proof > /projects/local-vm/proof.txt',{delay:25})
+  await page.keyboard.press('Enter')
+  const workspace=join(root,'web','runner-state','local-vm',app.runners.records()[0]!.id,'computer-workspaces',agent.id)
+  try {await expect.poll(()=>readFile(join(workspace,'proof.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('vm-ui-proof')}
+  catch(error){await page.screenshot({path:join(evidence,'input-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({inputs,failures,text:await viewer.innerText()})}`)}
+  await screen.focus()
+  await page.keyboard.type('erase-this',{delay:20});await page.keyboard.press('Control+u')
+  await page.keyboard.type('printf hotkey-proof > /projects/local-vm/hotkey.txt',{delay:20});await page.keyboard.press('Enter')
+  await expect.poll(()=>readFile(join(workspace,'hotkey.txt'),'utf8').catch(()=>''),{timeout:30000}).toBe('hotkey-proof')
+  await page.screenshot({path:join(evidence,'interactive-desktop.png')})
+  await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+  await dock.getByRole('button',{name:'停止虚拟机',exact:true}).click()
+  await expect.poll(async()=>await dock.innerText(),{timeout:20000}).toContain('虚拟机已停止')
+  await dock.getByRole('button',{name:'启动虚拟机',exact:true}).click()
+  await expect.poll(async()=>await dock.getByRole('img').count(),{timeout:60000}).toBe(1)
+  expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
+  const second=app.workspace.createAgent(owner,{name:'第二台本地虚拟机',profile:'default',execution:'computer'})
+  const cookies=setup.headers['set-cookie'].map((s:string)=>s.split(';')[0]).join('; ')
+  const fresh=await request(address).get('/api/app/bootstrap').set('Cookie',cookies).expect(200)
+  await request(address).post(`/api/app/agents/${second.id}/local-vm/create`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send({}).expect(200)
+  await expect.poll(async()=>(await request(address).get(`/api/app/agents/${second.id}/local-vm`).set('Cookie',cookies)).body.ready,{timeout:30000}).toBe(true)
+  await page.reload();await page.getByRole('button',{name:'电脑',exact:true}).click()
+  await dock.getByRole('button',{name:'打开双桌面',exact:true}).click()
+  const desktops=page.getByRole('main',{name:'双桌面工作区'})
+  await desktops.getByRole('combobox',{name:'选择桌面 2'}).selectOption(second.id)
+  await expect.poll(()=>desktops.getByRole('img').count(),{timeout:30000}).toBe(2)
+  await desktops.locator('.pane').first().getByRole('button',{name:'接管电脑',exact:true}).click()
+  await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('人工控制中')
+  await desktops.locator('.pane').last().getByRole('button',{name:'接管电脑',exact:true}).click()
+  await expect.poll(async()=>await desktops.locator('.pane').last().innerText(),{timeout:15000}).toContain('人工控制中')
+  await expect.poll(async()=>await desktops.locator('.pane').first().innerText(),{timeout:15000}).toContain('仅查看')
+  await expect.poll(()=>desktops.getByRole('img').count(),{timeout:15000}).toBe(2)
+  await expect.poll(()=>desktops.locator('.pane').last().locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
+  await page.screenshot({path:join(evidence,'two-desktops.png')})
+  await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
+ }finally{
+  await browser?.close();await node.close(true)
+  await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
+  await rm(root,{recursive:true,force:true})
+ }
+},240000)

+ 9 - 30
tests/live/worker.live.test.ts

@@ -89,36 +89,14 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
     }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
     void running.catch(()=>{})
     void running.catch(()=>{})
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
-    const imageCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
-    const imageBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)
-    const imageBase=`/api/app/admin/runners/${registered.runner.id}/images`
-    const imagesGet=async()=>(await request(address).get(imageBase).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
-    const imagesPost=(body:unknown)=>request(address).post(imageBase+'/jobs').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
-    const prepareId=randomUUID();await imagesPost({requestId:prepareId,operation:'prepare'}).expect(202)
-    let prepared:any
-    await expect.poll(async()=>{prepared=(await imagesGet()).jobs.find((j:any)=>j.id===prepareId);return prepared?.state},{timeout:90000}).toMatch(/complete|failed/)
-    expect(prepared.state,JSON.stringify(prepared)).toBe('complete')
-    const applyId=randomUUID();await imagesPost({requestId:applyId,operation:'activate',imageId:prepared.imageId}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===applyId)?.state,{timeout:60000}).toBe('complete')
-    expect((await imagesGet()).activeImageId).toBe(prepared.imageId)
-    const rollbackId=randomUUID();await imagesPost({requestId:rollbackId,operation:'rollback'}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===rollbackId)?.state,{timeout:60000}).toBe('complete')
-    expect((await imagesGet()).activeImageId).toBe(process.env.YAOYAO_COMPUTER_IMAGE)
-    const exportId=randomUUID();await imagesPost({requestId:exportId,operation:'export',imageId:process.env.YAOYAO_COMPUTER_IMAGE}).expect(202)
-    let exportedImage:any
-    await expect.poll(async()=>{exportedImage=(await imagesGet()).jobs.find((j:any)=>j.id===exportId);return exportedImage?.state},{timeout:60000}).toBe('complete')
-    const uploadId=randomUUID()
-    const uploadAPI=(path:string,body:unknown)=>request(address).post(imageBase+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
-    await uploadAPI('/uploads',{id:uploadId,size:exportedImage.archiveSize,manifest:exportedImage.manifest}).expect(200)
-    const {createHash}=await import('node:crypto');const archiveHash=createHash('sha256')
-    for(let offset=0;offset<exportedImage.archiveSize;){
-      const part=(await request(address).get(imageBase+`/jobs/${exportId}/chunk?offset=${offset}`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
-      const bytes=Buffer.from(part.data,'base64');archiveHash.update(bytes)
-      const accepted=await uploadAPI(`/uploads/${uploadId}`,{offset,data:part.data}).expect(200);offset+=bytes.length;expect(accepted.body.offset).toBe(offset)
-    }
-    expect(archiveHash.digest('hex')).toBe(exportedImage.manifest.archiveSha256)
-    const importId=randomUUID();await imagesPost({requestId:importId,operation:'import',uploadId}).expect(202)
-    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===importId)?.state,{timeout:60000}).toBe('complete')
+    const vmCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
+    const vmBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)
+    const vmGet=async()=>(await request(address).get('/api/app/admin/local-vm').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)).body
+    const prepareId=randomUUID()
+    await request(address).post('/api/app/admin/local-vm/prepare').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',vmCookies).set('X-CSRF-Token',vmBootstrap.body.csrfToken).send({requestId:prepareId}).expect(200)
+    await expect.poll(async()=>(await vmGet()).job?.state,{timeout:90000}).toBe('complete')
+    expect((await vmGet()).image).toBe(true)
+
 
 
     const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
     const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
     const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
     const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
@@ -195,6 +173,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await api('/giveback',{controlId:sharedTake.body.controlId,token:sharedTake.body.token,notes:''}).expect(200)
     await api('/giveback',{controlId:sharedTake.body.controlId,token:sharedTake.body.token,notes:''}).expect(200)
     await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(200)
     await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(200)
     expect(app.workspace.require<any>(owner,'agent',worker.id).computerEnvironmentId).toBeUndefined()
     expect(app.workspace.require<any>(owner,'agent',worker.id).computerEnvironmentId).toBeUndefined()
+    await sharedAPI('post',`/api/app/admin/local-vm/instances/${shared.body.computer.id}/stop`,{requestId:randomUUID()}).expect(200)
     const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
     const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
     const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
     const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
     const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})
     const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})

+ 6 - 0
tests/server/computerControl.test.ts

@@ -30,6 +30,12 @@ it('separates view from control and fences stale, duplicate and out-of-frame inp
     expect(await runtime.controls.input(meta,id,requestId,frame.generation,frame.id,action)).toEqual(first)
     expect(await runtime.controls.input(meta,id,requestId,frame.generation,frame.id,action)).toEqual(first)
     expect(execute).toHaveBeenCalledTimes(1)
     expect(execute).toHaveBeenCalledTimes(1)
     await expect(runtime.controls.input(meta,id,requestId,frame.generation,frame.id,{kind:'click',x:30,y:30})).rejects.toMatchObject({code:'idempotency_conflict'})
     await expect(runtime.controls.input(meta,id,requestId,frame.generation,frame.id,{kind:'click',x:30,y:30})).rejects.toMatchObject({code:'idempotency_conflict'})
+    await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'drag',fromX:2,fromY:3,toX:101,toY:4})).rejects.toMatchObject({code:'computer_point_invalid'})
+    await runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'drag',fromX:2,fromY:3,toX:20,toY:30})
+    expect(execute.mock.calls.at(-1)?.[1]).toContain('xdotool')
+    await runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,{kind:'key',key:'a',modifiers:['ctrl']})
+    expect(execute.mock.calls.at(-1)?.[1]).toContain('hotkey')
+    expect(JSON.parse(execute.mock.calls.at(-1)![1][3]!)).toMatchObject({keys:['ctrl','a']})
     permitted=false
     permitted=false
     await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,action)).rejects.toThrow('revoked')
     await expect(runtime.controls.input(meta,id,randomUUID(),frame.generation,frame.id,action)).rejects.toThrow('revoked')
     await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('off'),{timeout:6500,interval:100})
     await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('off'),{timeout:6500,interval:100})

+ 5 - 5
tests/server/desktopSync.test.ts

@@ -50,17 +50,17 @@ describe('desktop release selection and integrity', () => {
     release(); unlock()
     release(); unlock()
     const next = updateMutex(home); next()
     const next = updateMutex(home); next()
   })
   })
-  it('waits for asynchronous image jobs even after their start command has returned', async () => {
+  it('waits for Local VM preparation even after its command has returned', async () => {
     const store = { list: () => [], require: () => ({ enabled: true }), get: () => ({ enabled: true }) }
     const store = { list: () => [], require: () => ({ enabled: true }), get: () => ({ enabled: true }) }
     const auth = { pushAuthorizationVersion: () => 1, isAdminActive: () => true }
     const auth = { pushAuthorizationVersion: () => 1, isAdminActive: () => true }
     const hub = new RunnerHub(store as any, auth as any, {} as any)
     const hub = new RunnerHub(store as any, auth as any, {} as any)
     try {
     try {
-      ;(hub as any).online.set('runner', { features: ['image-management-v1'], seen: Date.now() })
-      ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'start' ? { job: { state: 'running' } } : { busy: false }
+      ;(hub as any).online.set('runner', { features: ['local-vm-v1'], seen: Date.now() })
+      ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'prepare' ? { job: { state: 'running' } } : { busy: false }
       expect(hub.idleForUpdate).toBe(true)
       expect(hub.idleForUpdate).toBe(true)
-      await hub.images('owner', 'runner', { op: 'start' })
+      await hub.localVm('owner', 'runner', { op: 'prepare' })
       expect(hub.idleForUpdate).toBe(false)
       expect(hub.idleForUpdate).toBe(false)
-      await hub.images('owner', 'runner', { op: 'status' })
+      await hub.localVm('owner', 'runner', { op: 'status' })
       expect(hub.idleForUpdate).toBe(true)
       expect(hub.idleForUpdate).toBe(true)
     } finally { hub.close() }
     } finally { hub.close() }
   })
   })

+ 0 - 48
tests/server/imageManagement.test.ts

@@ -1,48 +0,0 @@
-// @vitest-environment node
-import {it,expect,vi} from 'vitest'
-import {DatabaseSync} from 'node:sqlite'
-import {mkdtemp,rm,writeFile} from 'node:fs/promises'
-import {tmpdir} from 'node:os'
-import {join} from 'node:path'
-import {randomUUID} from 'node:crypto'
-import {ComputerRuntime} from '../../src/runner/worker/gateway'
-import {ImageManagement} from '../../src/runner/computers/imageManagement'
-import {fileSHA256} from '../../src/runner/computers/images'
-it('persists image selection, rejects in-use switching and recovers interrupted jobs without replay',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-ui-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64),next='sha256:'+'b'.repeat(64)
- const config:any={protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}}
- const runtime=new ComputerRuntime(db,config,home);let manager:ImageManagement|undefined
- try{await runtime.ready;manager=new ImageManagement(runtime,db,home);await manager.ready
-  const manifest={protocol:1 as const,imageId:next,architecture:'arm64' as const,driver:'0.20.0' as const,layer:'5' as const,createdAt:1}
-  vi.spyOn(manager.images,'inspect').mockResolvedValue(manifest)
-  vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',workspace:'/fixture',running:true,isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockResolvedValue();vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
-  const release=runtime.pool.beginMaintenance();await expect(manager.start('owner',randomUUID(),'activate',{imageId:next},async()=>{})).rejects.toMatchObject({code:'computer_busy'});release()
-  const id=randomUUID();await manager.start('owner',id,'activate',{imageId:next},async()=>{});await vi.waitFor(()=>expect(runtime.config.imageId).toBe(next));await manager.close()
-  expect(JSON.parse(String(db.prepare('SELECT value FROM managed_image_selection').get()!.value))).toMatchObject({active:next,previous:initial,base:initial})
-  config.computers.imageId=initial
-  const resumed=new ImageManagement(runtime,db,home);await resumed.ready;expect(runtime.config.imageId).toBe(next);await resumed.close()
- }finally{await manager?.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})
-it('uploads ordered bounded chunks, detects conflicting retries and isolates the uploader',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-upload-')),db=new DatabaseSync(':memory:'),runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home)
- const manager=new ImageManagement(runtime,db,home)
- try{await manager.ready;const id=randomUUID(),data=Buffer.from('image-fixture');const path=join(home,'hash');await writeFile(path,data)
-  await manager.beginUpload('owner',id,data.length,{protocol:1,imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1,archiveSha256:await fileSHA256(path)})
-  const bytes=data.toString('base64');expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length});expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length})
-  await expect(manager.appendUpload('other',id,0,bytes)).rejects.toMatchObject({code:'image_upload_missing'})
-  await expect(manager.appendUpload('owner',id,0,Buffer.from('wrong').toString('base64'))).rejects.toThrow()
-  await manager.cancelUpload('owner',id)
- }finally{await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})
-it('keeps the node in maintenance until uncertain validation cleanup is confirmed',async()=>{
- const home=await mkdtemp(join(tmpdir(),'yaoyao-images-cleanup-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64)
- const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home),manager=new ImageManagement(runtime,db,home)
- let stopped=false
- try{await manager.ready;vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1});vi.spyOn(manager.images,'run').mockResolvedValue('');vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:'/fixture',isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockImplementation(async()=>{if(!stopped)throw new Error('stop uncertain')});vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
-  await manager.start('owner',randomUUID(),'activate',{imageId:'sha256:'+'b'.repeat(64)},async()=>{})
-  await vi.waitFor(async()=>expect((await manager.inventory('owner')).jobs[0]?.state).toBe('uncertain'))
-  expect(()=>runtime.pool.beginMaintenance()).toThrow('请先停止');expect(runtime.config.imageId).toBe(initial)
-  stopped=true;expect((await manager.inventory('owner')).busy).toBe(false)
-  const release=runtime.pool.beginMaintenance();release()
- }finally{stopped=true;await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
-})

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

@@ -1,24 +0,0 @@
-// @vitest-environment node
-import {it,expect,vi} from 'vitest'
-import Koa from 'koa'
-import {bodyParser} from '@koa/bodyparser'
-import request from 'supertest'
-import {mkdtempSync,rmSync} from 'node:fs'
-import {tmpdir} from 'node:os'
-import {join} from 'node:path'
-import {randomUUID} from 'node:crypto'
-import {WorkspaceStore} from '../../src/server/workspaceStore'
-import {ImageManagementService} from '../../src/server/imageManagement'
-import {HttpError} from '../../src/server/errors'
-it('restricts image operations to admins and fences job grants to user version and Runner',async()=>{
- const home=mkdtempSync(join(tmpdir(),'yaoyao-image-route-')),store=new WorkspaceStore(home);let version=1,active=true
- const auth:any={requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes')throw new HttpError(403,'管理员权限','admin_required');return {id:'owner'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
- const images=vi.fn(async(owner:string,runnerId:string,p:any)=>({owner,runnerId,job:{id:p.id}})),service=new ImageManagementService(store,auth,{images} as any),app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
- try{const runner=randomUUID(),id=randomUUID(),base=`/api/app/admin/runners/${runner}/images`
-  await request(app.callback()).post(base+'/jobs').send({requestId:id,operation:'prepare'}).expect(403);expect(images).not.toHaveBeenCalled()
-  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare',path:'/etc/passwd'}).expect(400)
-  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare'}).expect(202)
-  expect(service.allowed(id,runner)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
-  version++;expect(service.allowed(id,runner)).toBe(false);version--;active=false;expect(service.allowed(id,runner)).toBe(false)
- }finally{store.close();rmSync(home,{recursive:true,force:true})}
-})

+ 88 - 0
tests/server/localVm.test.ts

@@ -0,0 +1,88 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+import {LocalVmImages} from '../../src/runner/computers/localVm'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../src/shared/runner'
+
+async function fixture(){
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-local-vm-')),db=new DatabaseSync(':memory:')
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:UNCONFIGURED_COMPUTER_IMAGE,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}},home)
+ await runtime.ready
+ const running=new Set<string>()
+ vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue()
+ vi.spyOn(runtime.provider,'ensure').mockImplementation(async s=>{running.add(s.id);return {id:s.id,containerId:s.id,workspace:'/fixture',running:true,isolation:'container'}})
+ vi.spyOn(runtime.provider,'stop').mockImplementation(async s=>{running.delete(s.id)})
+ vi.spyOn(runtime.provider,'remove').mockImplementation(async s=>{running.delete(s.id)})
+ vi.spyOn(runtime.provider,'health').mockResolvedValue({})
+ vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1280,height:900,capturedAt:Date.now()})
+ vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
+ return {db,runtime,running,async close(){await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}}
+}
+it('prepares one managed image, verifies the desktop, applies it and persists it without a catalog step',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
+ try{
+  vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
+  vi.spyOn(manager.images,'inspect').mockImplementation(async id=>{if(id!==imageId)throw new Error('not ready');return {protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1}})
+  await manager.ready
+  expect((await manager.status()).image).toBe(false)
+  const id=randomUUID(),check=vi.fn(async()=>{})
+  await manager.prepare(id,check)
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
+  expect(f.runtime.config.imageId).toBe(imageId)
+  expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
+  expect(f.running.size).toBe(0)
+  await manager.prepare(id,check)
+  expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
+  await manager.close()
+  const resumed=new LocalVmImages(f.runtime,f.db);await resumed.ready
+  expect(f.runtime.config.imageId).toBe(imageId);await resumed.close()
+ }finally{await manager.close();await f.close()}
+})
+it('retains completed desktop state but fences the old lease and counts idle desktops against capacity',async()=>{
+ const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
+ try{
+  await f.runtime.pool.desktop(spec,'create',()=>{})
+  expect(f.running.has(spec.id)).toBe(true)
+  expect((await f.runtime.controls.status({agentId:spec.id,environmentId:spec.id,ownerKey:'owner'})).mode).toBe('idle')
+  const lease=await f.runtime.pool.acquire(spec,'turn-1',()=>{})
+  await f.runtime.pool.release(lease,true)
+  expect(()=>f.runtime.pool.use(lease,async()=>{})).toThrow()
+  const second={...spec,id:randomUUID()};await f.runtime.pool.desktop(second,'create',()=>{})
+  await expect(f.runtime.pool.desktop({...spec,id:randomUUID()},'create',()=>{})).rejects.toMatchObject({code:'computer_quota'})
+  await expect(f.runtime.pool.desktop({...spec,ownerKey:'other'},'remove',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  await f.runtime.pool.desktop(spec,'stop',()=>{});expect(f.running.has(spec.id)).toBe(false)
+  await f.runtime.pool.desktop(spec,'start',()=>{});expect(f.running.has(spec.id)).toBe(true)
+  await f.runtime.pool.desktop(spec,'recreate',()=>{});expect(f.running.has(spec.id)).toBe(true)
+  await f.runtime.pool.desktop(spec,'remove',()=>{});expect(f.runtime.pool.definition('owner',spec.id)).toBeUndefined()
+  expect(f.runtime.provider.deleteWorkspace).not.toHaveBeenCalled()
+ }finally{await f.close()}
+})
+it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
+ const f=await fixture(),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
+ try{
+  const lease=await f.runtime.pool.acquire(spec,'human:test',()=>{})
+  for(const action of ['stop','remove','recreate'] as const)await expect(f.runtime.pool.desktop(spec,action,()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  await f.runtime.pool.release(lease)
+ }finally{await f.close()}
+})
+it('keeps uncertain preparation cleanup fenced and retries cleanup when runtime recovers',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),imageId='sha256:'+'a'.repeat(64)
+ let canStop=false
+ try{
+  await manager.ready
+  vi.spyOn(manager.images,'run').mockResolvedValue(imageId)
+  vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1})
+  vi.mocked(f.runtime.provider.remove).mockImplementation(async()=>{if(!canStop)throw new Error('stop uncertain')})
+  await manager.prepare(randomUUID(),async()=>{})
+  await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('failed'))
+  expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
+  expect(f.runtime.config.imageId).toBe(UNCONFIGURED_COMPUTER_IMAGE)
+  canStop=true;expect((await manager.status()).busy).toBe(false)
+  const release=f.runtime.pool.beginMaintenance();release()
+ }finally{canStop=true;await manager.close();await f.close()}
+})

+ 48 - 0
tests/server/localVmRoutes.test.ts

@@ -0,0 +1,48 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {LocalVmService} from '../../src/server/localVm'
+import {SharedComputers} from '../../src/server/sharedComputers'
+import {HttpError} from '../../src/server/errors'
+
+it('protects preparation, removes the old catalog API and scopes sharing to the account and profile',async()=>{
+ const home=mkdtempSync(join(tmpdir(),'local-vm-routes-')),store=new WorkspaceStore(home)
+ let version=1,active=true
+ const auth:any={require:(ctx:any)=>({id:ctx.get('x-owner')||'alice'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes'||!active)throw new HttpError(403,'需要管理员权限','admin_required');return {id:'alice'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
+ const runner={id:randomUUID(),enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
+ const nodes:any={requireSource:vi.fn()}
+ const hub:any={records:()=>[runner],summary:()=>({online:true}),localVm:vi.fn(async()=>({configured:true,daemonUp:true,image:true,mode:'per-bot',maxInstances:2,busy:false})),computer:vi.fn(async()=>({container:'running',ready:true}))}
+ const shared=new SharedComputers(store,auth,nodes,hub),service=new LocalVmService(store,auth,nodes,hub,shared,{home} as any),app=new Koa()
+ app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
+ try{
+  const first=store.createAgent('alice',{name:'甲',profile:'default',execution:'computer'}),second=store.createAgent('alice',{name:'乙',profile:'default',execution:'computer'})
+  const other=store.createAgent('bob',{name:'其他账号',profile:'default',execution:'computer'})
+  const id=randomUUID(),base='/api/app/admin/local-vm'
+  await request(app.callback()).post(base+'/prepare').send({requestId:id}).expect(403)
+  await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id,imageId:'arbitrary'}).expect(400)
+  await request(app.callback()).post(base+'/prepare').set('x-admin','yes').send({requestId:id}).expect(200)
+  expect(service.allowed(id,runner.id)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
+  version++;expect(service.allowed(id,runner.id)).toBe(false);version--
+  await request(app.callback()).get(`/api/app/admin/runners/${runner.id}/images`).set('x-admin','yes').expect(404)
+  await request(app.callback()).get(`/api/app/agents/${first.id}/local-vm`).set('x-owner','bob').expect(404)
+  await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(200)
+  const a=store.require<any>('alice','agent',first.id),b=store.require<any>('alice','agent',second.id)
+  expect(a.computerEnvironmentId).toBeTruthy();expect(b.computerEnvironmentId).toBe(a.computerEnvironmentId)
+  const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
+  expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)
+  const disabled=await request(app.callback()).put(`/api/app/agents/${later.id}/local-vm`).send({enabled:false}).expect(200)
+  expect(disabled.body.agent.execution).toBe('profile');expect(disabled.body.agent.computerEnvironmentId).toBeUndefined()
+  expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBe(a.computerEnvironmentId)
+  expect(store.require<any>('bob','agent',other.id).computerEnvironmentId).toBeUndefined()
+  await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'per-bot',maxInstances:2}).expect(200)
+  expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBeUndefined()
+  active=false;expect(service.allowed(id,runner.id)).toBe(false)
+ }finally{store.close();rmSync(home,{recursive:true,force:true})}
+})

+ 0 - 12
tests/server/runner.test.ts

@@ -264,15 +264,3 @@ it('keeps only stop operations available on an existing channel after task permi
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
 })
-it('keeps image download receipts small and reauthorizes immutable chunk retries',async()=>{
- const original=(runner as any).api.bind(runner)
- const api=vi.spyOn(runner as any,'api').mockImplementation(async(...args:any[])=>args[0]==='admit'?{allowed:true}:original(...args))
- const execute=vi.spyOn(runner as any,'execute').mockResolvedValue({data:Buffer.alloc(524288).toString('base64'),offset:0,size:524288})
- try{
-  const command:RunnerCommand={id:randomUUID(),kind:'images.manage',payload:{op:'download',owner:'fixture',id:randomUUID(),offset:0},expiresAt:Date.now()+30000}
-  expect(await runner.handle(command)).toHaveProperty('result.data')
-  expect((runner as any).db.prepare('SELECT state,result FROM commands WHERE id=?').get(command.id)).toMatchObject({state:'read',result:null})
-  expect(await runner.handle(command)).toHaveProperty('result.data');expect(execute).toHaveBeenCalledTimes(2)
-  await expect(runner.handle({...command,payload:{...command.payload,offset:1}})).rejects.toMatchObject({code:'idempotency_conflict'})
- }finally{api.mockRestore();execute.mockRestore()}
-})

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików