Browse Source

发布 v0.4.4:完善桌面接管并迁移夭夭 AI 数据与发布源

Samien 1 month ago
parent
commit
7430ea4393
67 changed files with 889 additions and 183 deletions
  1. 2 2
      .env.example
  2. 1 0
      .gitignore
  3. 4 2
      Dockerfile
  4. 188 44
      LICENSE
  5. 6 0
      NOTICE
  6. 8 8
      README.md
  7. 6 0
      bin/desktop-service-sync.mjs
  8. 8 4
      bin/hermes-yaoyao.mjs
  9. 4 0
      bin/lib/data-home.d.mts
  10. 24 0
      bin/lib/data-home.mjs
  11. 2 0
      bin/lib/data-migration.d.mts
  12. 107 0
      bin/lib/data-migration.mjs
  13. 45 0
      bin/lib/managed-data-migration.mjs
  14. 4 3
      bin/lib/release-source.mjs
  15. 20 0
      bin/migrate-data-home.mjs
  16. 4 4
      compose.desktops.yaml
  17. 4 4
      compose.yaml
  18. 42 0
      desktop/computer-viewer.mjs
  19. 42 0
      desktop/computer-viewer.test.mjs
  20. 13 1
      desktop/credentials.mjs
  21. 2 0
      desktop/data-home.mjs
  22. 14 7
      desktop/main.mjs
  23. 2 1
      desktop/preferences.mjs
  24. 7 0
      desktop/preload.cjs
  25. 1 0
      desktop/service-manager.mjs
  26. 1 0
      desktop/service-sync.mjs
  27. 3 3
      desktop/update-manager.test.mjs
  28. 3 3
      desktop/update-window.test.mjs
  29. 2 2
      docker.env.example
  30. 2 2
      docs/agent-install.md
  31. 2 2
      docs/chat-working-directory.md
  32. 1 1
      docs/computer-maintenance.md
  33. 3 3
      docs/docker-install.md
  34. 6 2
      docs/github-updates.md
  35. 4 4
      docs/macos-app.md
  36. 25 0
      docs/releases/v0.4.4.md
  37. 8 0
      docs/verification/2026-09-10-v044/README.md
  38. BIN
      docs/verification/2026-09-10-v044/computer-takeover.png
  39. 1 1
      index.html
  40. 6 5
      package-lock.json
  41. 12 6
      package.json
  42. 1 1
      public/manifest.webmanifest
  43. 3 3
      release.json
  44. 7 5
      scripts/build-desktop.mjs
  45. 1 1
      scripts/generate-desktop-compose.mjs
  46. 2 2
      src/client/App.vue
  47. 1 1
      src/client/components/app/RunnerSettingsPanel.vue
  48. 2 2
      src/client/components/app/WorkspaceShell.vue
  49. 1 1
      src/client/components/common/BrandMark.vue
  50. 32 16
      src/client/components/workspace/ComputerPanel.vue
  51. 1 1
      src/client/components/workspace/WorkspaceNodesPanel.vue
  52. 8 0
      src/client/env.d.ts
  53. 2 1
      src/client/router/index.ts
  54. 30 0
      src/client/views/ComputerViewerView.vue
  55. 5 1
      src/client/views/ConversationsView.vue
  56. 19 4
      src/runner/computers/container.ts
  57. 2 1
      src/server/config.ts
  58. 12 3
      src/server/index.ts
  59. 2 2
      tests/client/systemUpdatePanel.test.ts
  60. 8 8
      tests/e2e/workspace.spec.ts
  61. 2 2
      tests/fixtures/fake-hermes.mjs
  62. 24 2
      tests/server/computerProvider.test.ts
  63. 73 0
      tests/server/dataMigration.test.ts
  64. 1 1
      tests/server/desktopSync.test.ts
  65. 8 8
      tests/server/githubReleases.test.ts
  66. 1 1
      tests/server/security.test.ts
  67. 2 2
      tests/server/updateManager.test.ts

+ 2 - 2
.env.example

@@ -12,7 +12,7 @@ HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # HERMES_YAOYAO_TLS_CERT=/absolute/path/to/cert.pem
 # HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
-# HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao
+# YAOYAO_HOME=/Users/you/.yaoyao
 # iOS 后台通知使用 APNs Token Key。四项中前三项必须同时配置,.p8 不得放入仓库。
 # 任一 APNs 环境变量存在时,系统管理中的本地路径配置为只读;全部留空才可由 Web 管理。
 # HERMES_YAOYAO_APNS_KEY_FILE=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
@@ -25,7 +25,7 @@ HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # HERMES_YAOYAO_FCM_PROJECT_ID=your-firebase-project-id
 # HERMES_YAOYAO_FCM_PACKAGE_NAME=cn.samien.yaoyao.hermes
 # 固定 Git 发布源。系统更新只接受这里发现的 vX.Y.Z 标签,不接受浏览器指定仓库。
-# HERMES_YAOYAO_RELEASE_SOURCE=https://git.samien.cn/samien/hermes-yaoyao.git
+# HERMES_YAOYAO_RELEASE_SOURCE=https://github.com/Lsamien/yaoyao.git
 # HERMES_YAOYAO_RELEASE_ROOT=/Users/you/.local/share/hermes-yaoyao
 # 系统更新默认仅允许从本机浏览器触发;可信网络确有需要时才显式开启。
 # HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=0

+ 1 - 0
.gitignore

@@ -14,6 +14,7 @@ docker.env
 *.key
 hermes-yaoyao-*.tar
 .hermes-yaoyao/
+.yaoyao/
 .codegraph/
 .desktop-build/
 desktop-release/

+ 4 - 2
Dockerfile

@@ -27,16 +27,18 @@ FROM node:24-bookworm-slim AS runtime
 ENV NODE_ENV=production \
     HERMES_YAOYAO_HOST=0.0.0.0 \
     HERMES_YAOYAO_PORT=15300 \
-    HERMES_YAOYAO_HOME=/var/lib/hermes-yaoyao \
+    HERMES_YAOYAO_HOME=/home/node/.yaoyao \
     HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local \
     HERMES_YAOYAO_SUPERVISE_DASHBOARD=0 \
     HERMES_YAOYAO_LOCAL_VM_HOST=runner
 
 WORKDIR /app
 
-RUN install -d -o node -g node -m 0700 /var/lib/hermes-yaoyao
+RUN install -d -o node -g node -m 0700 /home/node/.yaoyao
 
 COPY --chown=node:node package.json package-lock.json release.json ./
+COPY --chown=node:node LICENSE NOTICE THIRD_PARTY_NOTICES.md ./
+COPY --chown=node:node licenses ./licenses
 COPY --chown=node:node --from=builder /app/node_modules ./node_modules
 COPY --chown=node:node --from=builder /app/dist ./dist
 COPY --chown=node:node --from=builder /app/dist-server ./dist-server

+ 188 - 44
LICENSE

@@ -1,58 +1,202 @@
-Business Source License 1.1
 
-Parameters
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
 
-Licensor:             EKKOLearnAI
-Licensed Work:        Hermes Studio, formerly known as Hermes Web UI,
-                      including the hermes-web-ui npm package and CLI,
-                      Hermes Studio desktop applications, web applications,
-                      server components, runtime integrations, firmware,
-                      release artifacts, documentation, and all associated
-                      files in this repository.
-Additional Use Grant: You may use the Licensed Work for non-commercial purposes,
-                      including personal use, education, and research.
-                      Commercial use (including but not limited to selling,
-                      licensing, SaaS hosting, or embedding in a commercial
-                      product) requires a separate commercial license from the
-                      Licensor.
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
-Change Date:          2029-05-10
+   1. Definitions.
 
-Change License:       Apache License 2.0
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
 
-Notice
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
 
-The Licensed Work is provided under the Business Source License 1.1 (BSL 1.1).
-You may not use the Licensed Work except in compliance with the License.
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
 
-You may obtain a copy of the License at
-https://mariadb.com/bsl11/
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
 
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
 
----
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
 
-For the purposes of this License:
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
 
-- "Licensed Work" refers to Hermes Studio and all source code,
-  documentation, firmware, desktop applications, web applications, server
-  components, runtime integrations, release artifacts, packages, command-line
-  tools, and associated files in this repository. This includes materials
-  distributed or identified under the current Hermes Studio name, the former
-  Hermes Web UI name, the hermes-web-ui npm package name, and the
-  hermes-studio desktop application name.
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
 
-- "Non-commercial use" means use that is not primarily intended for or
-  directed toward commercial advantage or monetary compensation.
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
 
-- The Licensor (EKKOLearnAI) and its authorized representatives are not
-  subject to the Additional Use Grant restrictions and may use the Licensed
-  Work for any purpose, including commercial purposes, without limitation.
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
 
-On the Change Date (2029-05-10), this License will automatically convert to
-the Apache License 2.0, at which point all users may use the Licensed Work
-under the terms of the Apache License 2.0.
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 6 - 0
NOTICE

@@ -0,0 +1,6 @@
+夭夭 AI
+Copyright 2026 Lsamien and Yaoyao contributors
+
+Licensed under the Apache License, Version 2.0.
+Third-party components retain their own licenses and copyright notices.
+See THIRD_PARTY_NOTICES.md and licenses/.

+ 8 - 8
README.md

@@ -1,8 +1,8 @@
-# 夭夭 Web
+# 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.3**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.3.md)。
+当前 Web 与 macOS 桌面发布版本:**v0.4.4**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.4.md)。
 
-夭夭 Web 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
+夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
 ## 聊天与 Agent
 
@@ -30,13 +30,13 @@ npm start
 HERMES_YAOYAO_UPSTREAM=http://服务器:9119 npm start
 ```
 
-Web 数据默认保存在 `~/.hermes-yaoyao`,可用 `HERMES_YAOYAO_HOME` 指定。备份应包含整个数据目录及加密密钥。
+Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HERMES_YAOYAO_HOME`)。旧默认目录 `~/.hermes-yaoyao` 会在服务空闲并停止后整体迁移至 `~/.yaoyao`,保留账号、会话、文件和密钥;两目录同时有数据时停止迁移,避免覆盖。备份应包含整个数据目录及加密密钥。
 
 由 Web 或 iOS 创建的普通聊天会按登录用户、Profile 和 Session ID 在 `chat-cache.sqlite3` 中登记所有权并持久化;后续续聊与列表展示以这份服务端登记为准,不再单独依赖 Hermes 的 `source` 标记。Web 的只读历史记录不进入该数据库,继续按需读取 9119。可通过 `HERMES_YAOYAO_CHAT_CACHE_MODE=upstream-only|shadow|prefer-local` 切换策略,默认 `prefer-local`。
 
 ## macOS 桌面版
 
-在 [v0.4.3 发布页](https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.3) 下载 `Yaoyao-0.4.3-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
+在 [v0.4.4 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.4) 下载 `Yaoyao-0.4.4-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
 
 此 DMG 使用 Apple Development 开发签名,未做 Apple 公证。
 
@@ -51,7 +51,7 @@ docker compose --env-file docker.env config --quiet
 docker compose --env-file docker.env up -d --build
 ```
 
-默认访问 `http://127.0.0.1:15300`。应用数据保存在 `yaoyao-data` 命名卷,容器内路径为 `/var/lib/hermes-yaoyao`。首次打开页面创建管理员账号,并在系统设置中配置 Hermes 连接。
+默认访问 `http://127.0.0.1:15300`。应用数据保存在 `yaoyao-data` 命名卷,容器内路径为 `/home/node/.yaoyao`。首次打开页面创建管理员账号,并在系统设置中配置 Hermes 连接。
 
 宿主机连接、局域网访问、验证及升级步骤见 [Docker 部署说明](docs/docker-install.md)。
 
@@ -61,7 +61,7 @@ iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号
 
 ## 数据与升级
 
-Web 默认从 [GitHub Releases](https://github.com/Lsamien/hermes-yaoyao/releases) 检查稳定版本;桌面菜单“检查 App 更新…”可下载并校验 DMG 后手动安装。旧官方发布源会自动归一化为 GitHub,自定义仓库保留。详见 [GitHub 版本升级](docs/github-updates.md)。
+Web 默认从 [GitHub Releases](https://github.com/Lsamien/yaoyao/releases) 检查稳定版本;桌面菜单“检查 App 更新…”可下载并校验 DMG 后手动安装。旧官方发布源会自动归一化为 GitHub,自定义仓库保留。详见 [GitHub 版本升级](docs/github-updates.md)。
 
 发布版本由 `release.json` 记录。macOS 本机服务支持在系统设置中升级 Web 或回滚到上一个版本;其他源码部署更新代码、重新构建并重启服务,Docker 部署通过重新构建镜像并重建容器更新。升级前备份 Web 数据目录,客户端版本要求见对应发布说明。
 
@@ -77,4 +77,4 @@ npm run build
 
 ## 许可证
 
-BSL-1.1,详见仓库 LICENSE。
+Apache-2.0,详见 [LICENSE](LICENSE)。第三方组件保留各自许可与署名,见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。

+ 6 - 0
bin/desktop-service-sync.mjs

@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
 import { createHash } from 'node:crypto'
 import { mkdirSync } from 'node:fs'
 import { LaunchAgentService, synchronizeDesktop, stopDesktopService } from './lib/service-update.mjs'
+import { migrateManagedDataHome } from './lib/managed-data-migration.mjs'
 
 // Losing the App's progress pipe must not interrupt a durable service transaction.
 process.stdout.on('error', error => { if (!['EPIPE', 'EIO'].includes(error.code)) process.exitCode = 1 })
@@ -13,6 +14,11 @@ try {
   const options = JSON.parse(input)
   const home = resolve(options.home), port = Number(options.port)
   if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('本机服务端口无效')
+  if (options.action === 'migrate') {
+    if (!options.fixture) await migrateManagedDataHome({ home, port, onProgress: message => process.stdout.write(JSON.stringify({ message }) + '\n') })
+    process.stdout.write(JSON.stringify({ result: { action: 'migrated' } }) + '\n')
+    process.exit(0)
+  }
   mkdirSync(home, { recursive: true, mode: 0o700 })
   // Fixture mode isolates *all* service/update state, including launchd labels.
   const fixture = options.fixture === true

+ 8 - 4
bin/hermes-yaoyao.mjs

@@ -8,6 +8,8 @@ import { dirname, join, resolve } from 'node:path'
 import { setTimeout as delay } from 'node:timers/promises'
 import { fileURLToPath } from 'node:url'
 import { normalizeReleaseSource } from './lib/release-source.mjs'
+import { resolveDataHome } from './lib/data-home.mjs'
+import { migrateDataHome } from './lib/data-migration.mjs'
 
 const label = 'com.samien.hermes-yaoyao'
 const sourceProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -16,7 +18,7 @@ const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
 const launchAgents = join(homedir(), 'Library', 'LaunchAgents')
 const plistPath = join(launchAgents, `${label}.plist`)
-const dataHome = process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
+const dataHome = resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME)
 const logDir = join(homedir(), 'Library', 'Logs')
 const serverEntry = join(serviceRoot, 'dist-server', 'server', 'index.js')
 
@@ -70,11 +72,12 @@ export function launchAgentPlist(options = {}) {
   const envEntries = {
     PATH: `${dirname(node)}:${join(homedir(), '.local', 'bin')}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
     NODE_ENV: 'production',
-    HERMES_YAOYAO_HOME: dataHome,
     HERMES_YAOYAO_PORT: '15300',
     HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119',
     HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1',
     ...environment(options.environment),
+    HERMES_YAOYAO_HOME: resolveDataHome(options.environment?.YAOYAO_HOME || options.environment?.HERMES_YAOYAO_HOME || dataHome),
+    YAOYAO_HOME: resolveDataHome(options.environment?.YAOYAO_HOME || options.environment?.HERMES_YAOYAO_HOME || dataHome),
     HERMES_YAOYAO_HOST: '0.0.0.0',
     HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
   }
@@ -153,9 +156,10 @@ async function install() {
     throw new Error('未找到生产构建,请先运行 npm run build')
   }
   await mkdir(launchAgents, { recursive: true })
-  await mkdir(dataHome, { recursive: true, mode: 0o700 })
   await mkdir(logDir, { recursive: true })
   if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+  migrateDataHome(dataHome)
+  await mkdir(dataHome, { recursive: true, mode: 0o700 })
   await writeFile(plistPath, launchAgentPlist(), { mode: 0o600 })
   await chmod(plistPath, 0o600)
   run('plutil', ['-lint', plistPath], { inherit: true })
@@ -222,7 +226,7 @@ async function main() {
     if (command === 'uninstall') return uninstall()
   }
   if (group === 'uploads' && command === 'prune') return pruneUploads(args)
-  process.stdout.write(`夭夭 Web\n\n用法:\n  hermes-yaoyao service install|start|stop|status|uninstall\n  hermes-yaoyao uploads prune --older-than 30 --yes\n`)
+  process.stdout.write(`夭夭 AI\n\n用法:\n  yaoyao service install|start|stop|status|uninstall\n  yaoyao uploads prune --older-than 30 --yes\n`)
 }
 
 export function isMainModule(entry = process.argv[1]) {

+ 4 - 0
bin/lib/data-home.d.mts

@@ -0,0 +1,4 @@
+export function legacyDataHome(userHome?: string): string
+export function defaultDataHome(userHome?: string): string
+export function legacyUpdateActive(userHome?: string): boolean
+export function resolveDataHome(value?: string, options?: {userHome?: string; preserveActiveUpdate?: boolean}): string

+ 24 - 0
bin/lib/data-home.mjs

@@ -0,0 +1,24 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+export const legacyDataHome = (userHome = homedir()) => join(userHome, '.hermes-yaoyao')
+export const defaultDataHome = (userHome = homedir()) => join(userHome, '.yaoyao')
+export function legacyUpdateActive(userHome = homedir()) {
+  const old = legacyDataHome(userHome)
+  try {
+    const { pid } = JSON.parse(readFileSync(join(old, 'updates', 'active.lock'), 'utf8'))
+    if (Number.isSafeInteger(pid) && pid > 0) {
+      try { process.kill(pid, 0); return true } catch (error) { if (error.code !== 'ESRCH') return true }
+    }
+  } catch { /* No active updater. */ }
+  return existsSync(join(old, 'updates', 'transition.json'))
+}
+/** Preserve explicit custom directories. Old official defaults migrate together. */
+export function resolveDataHome(value, { userHome = homedir(), preserveActiveUpdate = false } = {}) {
+  const requested = resolve(value?.trim() || defaultDataHome(userHome))
+  if (![legacyDataHome(userHome), defaultDataHome(userHome)].includes(requested)) return requested
+  // An old updater must finish its transaction against the original directory.
+  if (preserveActiveUpdate && legacyUpdateActive(userHome)) return legacyDataHome(userHome)
+  return defaultDataHome(userHome)
+}

+ 2 - 0
bin/lib/data-migration.d.mts

@@ -0,0 +1,2 @@
+export function migrateDataHome(home: string, options?: {userHome?: string}): boolean
+export function rebaseDataPath(value: unknown, old: string, home: string): unknown

+ 107 - 0
bin/lib/data-migration.mjs

@@ -0,0 +1,107 @@
+import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmdirSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { defaultDataHome, legacyDataHome, legacyUpdateActive } from './data-home.mjs'
+
+const markerName = '.data-home-migration.json'
+const read = file => JSON.parse(readFileSync(file, 'utf8'))
+function write(file, value) {
+  const temporary = file + '.tmp'
+  writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
+  renameSync(temporary, file)
+}
+export function rebaseDataPath(value, old, home) {
+  return typeof value === 'string' && (value === old || value.startsWith(old + '/')) ? home + value.slice(old.length) : value
+}
+/** Only storage paths are rebased; conversation text and external paths stay intact. */
+function rebaseDatabasePaths(home, old) {
+  for (const [name, table, columns] of [
+    ['uploads.sqlite3', 'uploads', ['path']],
+    ['chat-cache.sqlite3', 'chat_attachments', ['local_path', 'source_path']],
+  ]) {
+    const file = join(home, name)
+    if (!existsSync(file)) continue
+    const db = new DatabaseSync(file)
+    try {
+      if (!db.prepare('SELECT 1 FROM sqlite_master WHERE type=\'table\' AND name=?').get(table)) continue
+      const fields = new Set(db.prepare(`PRAGMA table_info("${table}")`).all().map(row => row.name))
+      db.exec('BEGIN IMMEDIATE')
+      for (const column of columns) if (fields.has(column)) {
+        db.prepare(`UPDATE "${table}" SET "${column}"=? || substr("${column}",?) WHERE "${column}"=? OR substr("${column}",1,?)=?`)
+          .run(home, old.length + 1, old, old.length + 1, old + '/')
+      }
+      db.exec('COMMIT; PRAGMA wal_checkpoint(TRUNCATE)')
+    } finally { db.close() }
+  }
+  const rebase = value => typeof value === 'string' ? rebaseDataPath(value, old, home)
+    : Array.isArray(value) ? value.map(rebase)
+    : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rebase(item)])) : value
+  // Durable update/notification configuration stores absolute paths as well.
+  for (const name of ['updates', 'push']) {
+    const directory = join(home, name)
+    if (!existsSync(directory)) continue
+    for (const entry of readdirSync(directory, { withFileTypes: true })) {
+      if (!entry.isFile() || !entry.name.endsWith('.json')) continue
+      const file = join(directory, entry.name), value = read(file), next = rebase(value)
+      if (JSON.stringify(value) !== JSON.stringify(next)) write(file, next)
+    }
+  }
+}
+export function migrateDataHome(home, { userHome = homedir() } = {}) {
+  home = resolve(home)
+  const old = legacyDataHome(userHome)
+  if (home !== defaultDataHome(userHome)) return false
+  const marker = join(home, markerName)
+  if (!existsSync(old)) {
+    if (existsSync(marker) && read(marker).phase === 'moving') {
+      const record = read(marker)
+      if (record.source !== old || record.target !== home) throw new Error('数据目录迁移记录不匹配')
+      rebaseDatabasePaths(home, old)
+      write(marker, { ...record, phase: 'complete' })
+    }
+    if (process.platform === 'linux' && home === '/home/node/.yaoyao' && existsSync(home)) {
+      const lock = new DatabaseSync(join(home, 'service-instance.sqlite3'))
+      try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); rebaseDatabasePaths(home, '/var/lib/hermes-yaoyao') }
+      finally { lock.close() }
+    }
+    return false
+  }
+  if (lstatSync(old).isSymbolicLink() || !lstatSync(old).isDirectory()) throw new Error('旧数据目录不是普通目录,请先核对实际数据位置')
+  if (existsSync(home) && (lstatSync(home).isSymbolicLink() || readdirSync(home).length))
+    throw new Error('~/.yaoyao 与 ~/.hermes-yaoyao 同时存在数据,已保留两份数据,请先合并确认后重试')
+  if (legacyUpdateActive(userHome)) throw new Error('旧版升级事务尚未完成,请完成更新后重试数据迁移')
+  const locks = []
+  try {
+    // Lock every SQLite database, including independent Runner owners, before moving.
+    const lockTree = directory => {
+      for (const entry of readdirSync(directory, { withFileTypes: true })) {
+        if (entry.isSymbolicLink() || ['backups', 'uploads', 'workspace-files', 'chat-cache-assets'].includes(entry.name)) continue
+        const path = join(directory, entry.name)
+        if (entry.isDirectory()) { if (['runner-state', 'updates'].includes(entry.name) || directory.startsWith(join(old, 'runner-state'))) lockTree(path) }
+        else if (/\.(sqlite3?|db)$/.test(entry.name)) {
+          const db = new DatabaseSync(path); locks.push(db)
+          db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE')
+        }
+      }
+    }
+    lockTree(old)
+    const recordFile = join(old, 'service-instance.json')
+    if (existsSync(recordFile)) {
+      const { pid } = read(recordFile)
+      if (Number.isSafeInteger(pid) && pid > 0) {
+        try { process.kill(pid, 0); throw new Error('旧版服务仍在运行,请停止后台服务后重试数据迁移') }
+        catch (error) { if (error.code !== 'ESRCH') throw error }
+      }
+    }
+    write(join(old, markerName), { source: old, target: home, phase: 'moving', startedAt: new Date().toISOString() })
+    if (existsSync(home)) rmdirSync(home)
+    renameSync(old, home)
+  } catch (error) {
+    if (/locked|busy/i.test(error.message)) throw new Error('旧数据仍被服务或执行节点使用,停止后才能迁移')
+    throw error
+  } finally { for (const db of locks.reverse()) db.close() }
+  rebaseDatabasePaths(home, old)
+  write(marker, { ...read(marker), phase: 'complete' })
+  return true
+}

+ 45 - 0
bin/lib/managed-data-migration.mjs

@@ -0,0 +1,45 @@
+import { existsSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { LaunchAgentService } from './service-update.mjs'
+import { defaultDataHome, legacyDataHome } from './data-home.mjs'
+import { migrateDataHome, rebaseDataPath } from './data-migration.mjs'
+
+export async function migrateManagedDataHome({ home, port = 15300, onProgress = () => {}, beforeMove }) {
+  if (home !== defaultDataHome()) return
+  const old = legacyDataHome(), releaseRoot = join(homedir(), '.local', 'share', 'hermes-yaoyao')
+  let previous, driver
+  if (existsSync(old)) {
+    driver = new LaunchAgentService({ home: old, port, releaseRoot })
+    previous = driver.snapshot()
+    if (previous) {
+      onProgress('正在等待旧服务空闲,准备迁移数据到 ~/.yaoyao…')
+      await driver.quiesce(onProgress, 0)
+      await driver.stop()
+    }
+  }
+  try {
+    if (existsSync(old)) await beforeMove?.(old)
+    migrateDataHome(home)
+  } catch (error) {
+    if (previous && existsSync(old)) await driver.restore(previous)
+    throw error
+  }
+  // Also recover a crash between the atomic rename and the LaunchAgent rewrite.
+  if (!existsSync(home)) return
+  const currentDriver = new LaunchAgentService({ home, port, releaseRoot })
+  if (!previous && existsSync(join(home, '.data-home-migration.json')) && existsSync(currentDriver.plistPath)) {
+    const { execFileSync } = await import('node:child_process')
+    const plist = JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', currentDriver.plistPath], { encoding: 'utf8' }))
+    if (plist.EnvironmentVariables?.HERMES_YAOYAO_HOME === old) previous = { plist, wasRunning: true }
+  }
+  if (previous) {
+    for (const [key, value] of Object.entries(previous.plist.EnvironmentVariables ?? {})) previous.plist.EnvironmentVariables[key] = rebaseDataPath(value, old, home)
+    previous.plist.EnvironmentVariables.HERMES_YAOYAO_HOME = home
+    previous.plist.EnvironmentVariables.YAOYAO_HOME = home
+    for (const field of ['StandardOutPath', 'StandardErrorPath']) previous.plist[field] = rebaseDataPath(previous.plist[field], old, home)
+    currentDriver.writePlist(previous.plist)
+    if (previous.wasRunning) { await currentDriver.boot(); await currentDriver.verify(undefined) }
+    onProgress('账号、会话、文件和配置已迁移到 ~/.yaoyao')
+  }
+}

+ 4 - 3
bin/lib/release-source.mjs

@@ -1,9 +1,10 @@
-export const DEFAULT_RELEASE_SOURCE = 'https://github.com/Lsamien/hermes-yaoyao.git'
+export const DEFAULT_RELEASE_SOURCE = 'https://github.com/Lsamien/yaoyao.git'
 
 /** Only the former official repository is migrated; private forks stay private. */
 export function normalizeReleaseSource(value) {
   const source = value?.trim()
   if (!source) return DEFAULT_RELEASE_SOURCE
+  if (/^git@github\.com:Lsamien\/(?:hermes-yaoyao|yaoyao)(?:\.git)?\/?$/i.test(source)) return DEFAULT_RELEASE_SOURCE
   if (/^git@git\.samien\.cn:samien\/hermes-yaoyao(?:\.git)?\/?$/.test(source)) return DEFAULT_RELEASE_SOURCE
   try {
     const url = new URL(source)
@@ -11,10 +12,10 @@ export function normalizeReleaseSource(value) {
     if (!url.username && !url.password && !url.search && !url.hash
       && ['https:', 'http:', 'ssh:'].includes(url.protocol)
       && ['git.samien.cn', '192.168.153.8:3000'].includes(url.host)
-      && path === '/samien/hermes-yaoyao') return DEFAULT_RELEASE_SOURCE
+      && ['/samien/hermes-yaoyao', '/samien/yaoyao'].includes(path)) return DEFAULT_RELEASE_SOURCE
     if (url.protocol === 'https:' && url.hostname === 'github.com' && !url.port
       && !url.username && !url.password && !url.search && !url.hash && /^\/[\w.-]+\/[\w.-]+$/.test(path)) {
-      const normalized = `https://github.com${path}.git`
+      const normalized = /^\/Lsamien\/(?:hermes-yaoyao|yaoyao)$/i.test(path) ? DEFAULT_RELEASE_SOURCE : `https://github.com${path}.git`
       return normalized.toLowerCase() === DEFAULT_RELEASE_SOURCE.toLowerCase() ? DEFAULT_RELEASE_SOURCE : normalized
     }
   } catch { /* Custom SSH sources retain their existing Git transport. */ }

+ 20 - 0
bin/migrate-data-home.mjs

@@ -0,0 +1,20 @@
+import { appendFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { defaultDataHome, legacyDataHome, legacyUpdateActive } from './lib/data-home.mjs'
+import { migrateManagedDataHome } from './lib/managed-data-migration.mjs'
+
+const deadline = Date.now() + 10 * 60 * 1000
+for (;;) {
+  try {
+    if (legacyUpdateActive()) throw new Error('旧版升级事务仍在运行')
+    await migrateManagedDataHome({ home: defaultDataHome(), port: Number(process.argv[2] || 15300) })
+    break
+  } catch (error) {
+    if (Date.now() >= deadline) {
+      try { appendFileSync(join(legacyDataHome(), 'data-migration.log'), `${new Date().toISOString()} ${error.message}\n`, { mode: 0o600 }) } catch {}
+      process.exitCode = 1
+      break
+    }
+    await new Promise(done => setTimeout(done, 5000))
+  }
+}

+ 4 - 4
compose.desktops.yaml

@@ -6,7 +6,7 @@ services:
     build:
       context: .
       dockerfile: Dockerfile
-    image: "${HERMES_YAOYAO_IMAGE:-hermes-yaoyao:local}"
+    image: "${HERMES_YAOYAO_IMAGE:-yaoyao:local}"
     restart: unless-stopped
     init: true
     read_only: true
@@ -31,14 +31,14 @@ services:
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
       HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\"}]"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
-      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/hermes-yaoyao.git}"
-      HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
+      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/yaoyao.git}"
+      HERMES_YAOYAO_HOME: /home/node/.yaoyao
       HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
       HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"
       HERMES_YAOYAO_APNS_TEAM_ID: "${HERMES_YAOYAO_APNS_TEAM_ID:-}"
       HERMES_YAOYAO_APNS_TOPIC: "${HERMES_YAOYAO_APNS_TOPIC:-}"
     volumes:
-      - yaoyao-data:/var/lib/hermes-yaoyao
+      - yaoyao-data:/home/node/.yaoyao
       - desktop-1-ipc:/run/yaoyao-desktops/desktop-1:ro
       - desktop-2-ipc:/run/yaoyao-desktops/desktop-2:ro
     tmpfs:

+ 4 - 4
compose.yaml

@@ -5,7 +5,7 @@ services:
     build:
       context: .
       dockerfile: Dockerfile
-    image: "${HERMES_YAOYAO_IMAGE:-hermes-yaoyao:local}"
+    image: "${HERMES_YAOYAO_IMAGE:-yaoyao:local}"
     restart: unless-stopped
     init: true
     read_only: true
@@ -29,14 +29,14 @@ services:
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
-      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/hermes-yaoyao.git}"
-      HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
+      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/yaoyao.git}"
+      HERMES_YAOYAO_HOME: /home/node/.yaoyao
       HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
       HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"
       HERMES_YAOYAO_APNS_TEAM_ID: "${HERMES_YAOYAO_APNS_TEAM_ID:-}"
       HERMES_YAOYAO_APNS_TOPIC: "${HERMES_YAOYAO_APNS_TOPIC:-}"
     volumes:
-      - yaoyao-data:/var/lib/hermes-yaoyao
+      - yaoyao-data:/home/node/.yaoyao
     tmpfs:
       - /tmp:mode=1777
 

+ 42 - 0
desktop/computer-viewer.mjs

@@ -0,0 +1,42 @@
+import { BrowserWindow, ipcMain } from 'electron'
+
+/** Local authenticated viewer. Agent ownership and control leases remain server-authoritative. */
+export function installComputerViewer({ owner, origin, preload, quitting = () => false }) {
+  let viewer, agentId, approvedClose = false
+  const trusted = event => event.sender === owner()?.webContents && event.senderFrame === event.sender.mainFrame
+    && new URL(event.senderFrame.url).origin === origin()
+  ipcMain.handle('desktop-computer:open', async (event, id) => {
+    if (!trusted(event) || typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) throw new Error('电脑窗口请求无效')
+    if (viewer && !viewer.isDestroyed()) {
+      viewer.show(); viewer.focus()
+      if (agentId !== id) throw new Error('请先交还并关闭当前电脑,再接管另一台电脑')
+      return true
+    }
+    const url = new URL(`/conversations/computer/${encodeURIComponent(id)}`, origin()).href
+    agentId = id; approvedClose = false
+    const current = viewer = new BrowserWindow({ parent: owner(), modal: false, width: 1220, height: 820,
+      minWidth: 760, minHeight: 520, show: false, title: '电脑接管 · 夭夭 AI', backgroundColor: '#111315', autoHideMenuBar: true,
+      webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true } })
+    current.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+    current.webContents.on('will-navigate', (event, target) => { if (target !== url) event.preventDefault() })
+    current.webContents.on('will-redirect', event => event.preventDefault())
+    current.webContents.on('will-attach-webview', event => event.preventDefault())
+    current.on('close', event => {
+      if (!approvedClose && !quitting() && !current.webContents.isCrashed()) {
+        event.preventDefault(); current.webContents.send('desktop-computer:request-close')
+      }
+    })
+    current.on('closed', () => { if (viewer === current) { viewer = undefined; agentId = undefined } })
+    current.once('ready-to-show', () => { current.show(); current.focus() })
+    try { await current.loadURL(url) } catch (error) { approvedClose = true; current.close(); throw error }
+    return true
+  })
+  ipcMain.handle('desktop-computer:closed', event => {
+    if (event.sender !== viewer?.webContents || event.senderFrame !== event.sender.mainFrame
+      || new URL(event.senderFrame.url).pathname !== `/conversations/computer/${agentId}`) throw new Error('电脑窗口身份无效')
+    approvedClose = true; viewer.close()
+  })
+  const close = () => { approvedClose = true; viewer?.destroy() }
+  close.owns = contents => contents === viewer?.webContents
+  return close
+}

+ 42 - 0
desktop/computer-viewer.test.mjs

@@ -0,0 +1,42 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { spawn } from 'node:child_process'
+import { _electron as electron, expect } from '@playwright/test'
+const root=resolve(import.meta.dirname,'..')
+test('desktop takeover opens a separate large viewer and returns its lease before native close',{timeout:90000},async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-viewer-')),port=18864,origin=`http://127.0.0.1:${port}`
+ const server=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{cwd:root,env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_TEAM_TOOLS:'1',WORKSPACE_FIXTURE_PORT:String(port),WORKSPACE_FIXTURE_UPSTREAM_PORT:'19164'},stdio:'ignore'})
+ let app
+ try{
+  await expect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
+  const harness=join(home,'main.mjs')
+  await writeFile(harness,`import {app,BrowserWindow} from '${pathToFileURL(join(root,'node_modules/electron/index.js')).href}';\nimport {installComputerViewer} from '${pathToFileURL(join(root,'desktop/computer-viewer.mjs')).href}';\napp.setPath('userData',${JSON.stringify(join(home,'chromium'))});app.whenReady().then(async()=>{let quitting=false;app.on('before-quit',()=>quitting=true);const owner=new BrowserWindow({width:1440,height:900,webPreferences:{preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},sandbox:true,contextIsolation:true}});installComputerViewer({owner:()=>owner,origin:()=>${JSON.stringify(origin)},preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},quitting:()=>quitting});await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
+  // Electron's built-in module must be resolved in its own process.
+  const content=await (await import('node:fs/promises')).readFile(harness,'utf8');await writeFile(harness,content.replace(pathToFileURL(join(root,'node_modules/electron/index.js')).href,'electron'))
+  app=await electron.launch({args:[harness],cwd:root})
+  const page=await app.firstWindow();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 seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
+  await page.goto(origin+'/conversations/'+seed.conversationId)
+  const agents=(await(await page.request.get(origin+'/api/app/agents')).json()).agents
+  const agent=agents.find(item=>item.id===seed.agentId)||agents.find(item=>item.execution==='computer')||agents[0]
+  const opened=app.waitForEvent('window');await page.evaluate(id=>window.yaoyaoDesktop.openComputer(id),agent.id)
+  const viewer=await opened;await expect(viewer.locator('.computer-panel.standalone')).toContainText('人工控制中')
+  await expect(viewer.locator('.computer-screen img')).toBeVisible()
+  await expect(viewer.locator('footer')).toBeHidden()
+  const screen=await viewer.locator('.computer-screen').boundingBox();assert.ok(screen.height>650)
+  await viewer.locator('.computer-screen img').click();await viewer.keyboard.type('takeover-'+Date.now())
+  await expect.poll(async()=>(await(await page.request.get(origin+'/__test/computer')).json()).actions.filter(item=>item.kind==='text').length).toBeGreaterThan(0)
+  await mkdir(join(root,'docs/verification/2026-09-10-v044'),{recursive:true});await viewer.screenshot({path:join(root,'docs/verification/2026-09-10-v044/computer-takeover.png')})
+  await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click();await expect(viewer.getByRole('textbox',{name:'交还说明'})).toBeVisible()
+  await viewer.getByRole('textbox',{name:'交还说明'}).fill('已完成人工操作')
+  await app.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows().find(win=>win.webContents.getURL().includes('/conversations/computer/')).close())
+  await expect.poll(()=>viewer.isClosed()).toBe(true)
+  const status=await(await page.request.get(origin+`/api/app/agents/${agent.id}/computer`)).json();assert.notEqual(status.mode,'human')
+  assert.equal(page.isClosed(),false)
+ }finally{await app?.close().catch(()=>{});server.kill('SIGTERM');if(server.exitCode===null)await new Promise(done=>server.once('exit',done));await rm(home,{recursive:true,force:true})}
+})

+ 13 - 1
desktop/credentials.mjs

@@ -1,8 +1,20 @@
 import {createHash} from 'node:crypto'
 import {execFile} from 'node:child_process'
+import {readFileSync} from 'node:fs'
+import {join} from 'node:path'
+import {homedir} from 'node:os'
 const prefix=Buffer.from('YAOYAO-RUNNER-KEYCHAIN-1\n')
 export class DesktopCredentials {
-  constructor({home,helper,legacyDecrypt}){this.account=createHash('sha256').update(home).digest('hex');this.helper=helper;this.legacyDecrypt=legacyDecrypt}
+  constructor({home,helper,legacyDecrypt}){this.home=home;this.helper=helper;this.legacyDecrypt=legacyDecrypt}
+  get account(){
+    let identity=this.home
+    try {
+      const migration=JSON.parse(readFileSync(join(this.home,'.data-home-migration.json'),'utf8'))
+      const old=join(homedir(),'.hermes-yaoyao')
+      if(this.home===join(homedir(),'.yaoyao')&&migration.source===old&&migration.target===this.home)identity=old
+    }catch{/* Fresh directories retain their own Keychain identity. */}
+    return createHash('sha256').update(identity).digest('hex')
+  }
   async invoke(operation,input){
     if(input.length>1048576)throw new Error('执行节点配置超过加密大小上限')
     return new Promise((resolve,reject)=>{

+ 2 - 0
desktop/data-home.mjs

@@ -0,0 +1,2 @@
+// Source-mode counterpart; packaging copies the shared module beside main.mjs.
+export { resolveDataHome } from '../bin/lib/data-home.mjs'

+ 14 - 7
desktop/main.mjs

@@ -7,9 +7,11 @@ import { DesktopServiceManager } from './service-manager.mjs'
 import { DesktopRunnerManager } from './runner-manager.mjs'
 import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
-import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
+import { synchronizeLocalService, stopLocalService, migrateLocalData } from './service-sync.mjs'
+import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost } from './environment-host.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
+import { installComputerViewer } from './computer-viewer.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -20,6 +22,9 @@ if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
   let updateWindow, updater, environmentHost
+  const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => manager?.state.url,
+    preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
+  app.on('will-quit', closeComputerViewer)
   const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
   const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
   const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
@@ -27,7 +32,7 @@ else {
   mkdirSync(logRoot, { recursive: true })
   const logFile = join(logRoot, 'server.log')
   const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
-  const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
+  const home = fixtureHome || resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME)
   const preferences = new DesktopPreferences(home)
   let startHidden = false
   const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
@@ -56,7 +61,7 @@ else {
       if (action === 'check') return updater.check()
       if (action === 'download') return updater.download()
       if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
-      if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/hermes-yaoyao/releases'); return }
+      if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/yaoyao/releases'); return }
       const path = await updater.verifiedFile()
       if (closing || quitting || !trustedUpdate(event)) return
       if (action === 'folder') shell.showItemInFolder(path)
@@ -112,8 +117,10 @@ else {
       catch { return false }
     }
     window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
-      trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
+      closeComputerViewer.owns(contents) ? ['fullscreen', 'clipboard-sanitized-write'].includes(permission)
+        : trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
     window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
+      if (closeComputerViewer.owns(contents)) { callback(['fullscreen', 'clipboard-sanitized-write'].includes(permission)); return }
       if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
       if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
         void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
@@ -194,6 +201,7 @@ else {
       fetchImpl: (...args) => net.fetch(...args) })
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
+      prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
       stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
         ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
       synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
@@ -214,8 +222,7 @@ else {
     })
     environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
     const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
-    mkdirSync(home,{recursive:true,mode:0o700})
-    const credentials=new DesktopCredentials({home:realpathSync(home),helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
+    const credentials=new DesktopCredentials({home,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
     runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
       encrypt:value=>credentials.encrypt(value),
       decrypt:bytes=>credentials.decrypt(bytes),
@@ -265,6 +272,6 @@ else {
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
     await manager.start().catch(error => log(error.message))
     if(!closing)environmentHost.start()
-    if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
+    if(!closing&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 2 - 1
desktop/preferences.mjs

@@ -1,9 +1,10 @@
 import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
+import {homedir} from 'node:os'
 export class DesktopPreferences {
   constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false}}
-  async load(){try{const value=JSON.parse(await readFile(this.path,'utf8'));this.value={backgroundAtLogin:value.backgroundAtLogin===true}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
+  async load(){try{let source;try{source=await readFile(this.path,'utf8')}catch(error){if(error.code!=='ENOENT'||this.home!==join(homedir(),'.yaoyao'))throw error;source=await readFile(join(homedir(),'.hermes-yaoyao','desktop-preferences.json'),'utf8')}const value=JSON.parse(source);this.value={backgroundAtLogin:value.backgroundAtLogin===true}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
   async setBackgroundAtLogin(enabled){
     const value={backgroundAtLogin:enabled===true},temporary=this.path+'.'+randomUUID()
     await mkdir(this.home,{recursive:true,mode:0o700})

+ 7 - 0
desktop/preload.cjs

@@ -3,4 +3,11 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   status: () => ipcRenderer.invoke('desktop:status'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
+  openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),
+  computerClosed: () => ipcRenderer.invoke('desktop-computer:closed'),
+  onComputerClose: callback => {
+    const listener = () => callback()
+    ipcRenderer.on('desktop-computer:request-close', listener)
+    return () => ipcRenderer.removeListener('desktop-computer:request-close', listener)
+  },
 }))

+ 1 - 0
desktop/service-manager.mjs

@@ -113,6 +113,7 @@ export class DesktopServiceManager {
     const generation = ++this.generation
     this.publish({ phase: 'starting', message: '正在启动本地服务…' })
     try {
+      await this.options.prepareHome?.(message => this.publish({ phase: 'starting', message }))
       await mkdir(this.options.home, { recursive: true, mode: 0o700 })
       this.root = await realpath(this.options.home)
       this.updateNotice = undefined

+ 1 - 0
desktop/service-sync.mjs

@@ -5,6 +5,7 @@ import { join } from 'node:path'
  * The renderer never receives its configuration or a process-control bridge. */
 export const synchronizeLocalService = options => serviceCommand({...options,action:'sync'})
 export const stopLocalService = options => serviceCommand({...options,action:'stop'})
+export const migrateLocalData = options => serviceCommand({...options,action:'migrate'})
 function serviceCommand({ home, port, root, fixture, environment, onProgress = () => {}, action }) {
   return new Promise((done, reject) => {
     const runtimeRoot = join(root, 'web-service')

+ 3 - 3
desktop/update-manager.test.mjs

@@ -7,18 +7,18 @@ import { createHash } from 'node:crypto'
 import { DesktopUpdateManager, githubDownload } from './update-manager.mjs'
 
 const hash = bytes => createHash('sha256').update(bytes).digest('hex')
-const prefix = 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/'
+const prefix = 'https://github.com/Lsamien/yaoyao/releases/download/v0.4.2/'
 const name = 'Yaoyao-0.4.2-arm64.dmg', data = Buffer.from('fixture-image')
 async function fixture(overrides = {}) {
   const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-unit-'))
   const sums = Buffer.from(`${hash(data)}  ${name}\n`)
-  const release = { manifest: { webVersion: '0.4.2' }, commit: 'a'.repeat(40), notes: 'Notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2', assets: [
+  const release = { manifest: { webVersion: '0.4.2' }, commit: 'a'.repeat(40), notes: 'Notes', releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases/tag/v0.4.2', assets: [
     { name, size: data.length, digest: `sha256:${hash(data)}`, url: prefix + name },
     { name: 'SHA256SUMS.txt', size: sums.length, digest: `sha256:${hash(sums)}`, url: prefix + 'SHA256SUMS.txt' },
   ] }
   const calls = [], verified = []
   const manager = new DesktopUpdateManager({ version: '0.4.1', arch: 'arm64', platform: 'darwin', cacheRoot: home,
-    source: 'https://github.com/Lsamien/hermes-yaoyao.git', inspect: async () => release,
+    source: 'https://github.com/Lsamien/yaoyao.git', inspect: async () => release,
     compare: (a, b) => Number(a.split('.').at(-1)) - Number(b.split('.').at(-1)),
     fetchImpl: async url => { calls.push(url); return new Response(url.endsWith('.dmg') ? data : sums) },
     verifyImage: async path => { verified.push(path) }, ...overrides })

+ 3 - 3
desktop/update-window.test.mjs

@@ -40,8 +40,8 @@ test('native update window works without a Web service, verifies a real DMG and
     assert.equal(denied, true)
     const opened = app.waitForEvent('window')
     await app.evaluate(({ net, shell, Menu }, fixture) => {
-      const tag = `v${fixture.version}`, api = 'https://api.github.com/repos/Lsamien/hermes-yaoyao'
-      const prefix = `https://github.com/Lsamien/hermes-yaoyao/releases/download/${tag}/`
+      const tag = `v${fixture.version}`, api = 'https://api.github.com/repos/Lsamien/yaoyao'
+      const prefix = `https://github.com/Lsamien/yaoyao/releases/download/${tag}/`
       const commit = 'a'.repeat(40)
       net.fetch = async url => {
         const path = String(url)
@@ -50,7 +50,7 @@ test('native update window works without a Web service, verifies a real DMG and
           { name: 'SHA256SUMS.txt', size: fixture.sums.length, state: 'uploaded', browser_download_url: prefix + 'SHA256SUMS.txt' },
         ] })
         if (path === `${api}/git/ref/tags/${tag}`) return Response.json({ object: { type: 'commit', sha: commit } })
-        if (path === `https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`) return Response.json({ schemaVersion: 1, releaseVersion: fixture.version, webVersion: fixture.version, gitTag: tag })
+        if (path === `https://raw.githubusercontent.com/Lsamien/yaoyao/${commit}/release.json`) return Response.json({ schemaVersion: 1, releaseVersion: fixture.version, webVersion: fixture.version, gitTag: tag })
         if (path === prefix + 'SHA256SUMS.txt') return new Response(fixture.sums)
         if (path === prefix + fixture.name) return new Response(Buffer.from(fixture.data, 'base64'))
         throw new Error('Unexpected update request')

+ 2 - 2
docker.env.example

@@ -2,7 +2,7 @@
 HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 可选:使用已经导入或发布的镜像;留空时使用本地构建名称。
-# HERMES_YAOYAO_IMAGE=hermes-yaoyao:local
+# HERMES_YAOYAO_IMAGE=yaoyao:local
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 
 # 部署步骤见 docs/docker-install.md;使用 docker compose --env-file docker.env 启动。
@@ -11,7 +11,7 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 官方稳定发布源;自定义 Git 仓库可覆盖。容器仍由部署者重建升级。
-HERMES_YAOYAO_RELEASE_SOURCE=https://github.com/Lsamien/hermes-yaoyao.git
+HERMES_YAOYAO_RELEASE_SOURCE=https://github.com/Lsamien/yaoyao.git
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
 # 使用 compose.desktops.yaml 可同时部署固定数量的共享桌面容器。
 # 桌面数量以 Compose 为准,Web 仅连接已有桌面;详见 docs/docker-install.md。

+ 2 - 2
docs/agent-install.md

@@ -1,4 +1,4 @@
-# 部署夭夭 Web
+# 部署夭夭 AI
 
 ## 前提
 
@@ -26,7 +26,7 @@ docker compose --env-file docker.env config --quiet
 docker compose --env-file docker.env up -d --build
 ```
 
-Compose 将 Web 的 15300 端口发布到宿主机,使用 `yaoyao-data` 命名卷保存 `/var/lib/hermes-yaoyao`。宿主机连接、访问配置、备份及升级见 [Docker 部署说明](docker-install.md)。
+Compose 将 Web 的 15300 端口发布到宿主机,使用 `yaoyao-data` 命名卷保存 `/home/node/.yaoyao`。宿主机连接、访问配置、备份及升级见 [Docker 部署说明](docker-install.md)。
 
 ## 验收
 

+ 2 - 2
docs/chat-working-directory.md

@@ -1,6 +1,6 @@
 # 聊天工作目录
 
-夭夭 Web 与移动端通过同一个服务端执行规则,使用所选基础 Hermes Profile 的 `terminal.cwd`。客户端不决定服务器路径,也不把手机沙盒、Web 启动目录或历史会话目录作为覆盖值。
+夭夭 AI 与移动端通过同一个服务端执行规则,使用所选基础 Hermes Profile 的 `terminal.cwd`。客户端不决定服务器路径,也不把手机沙盒、Web 启动目录或历史会话目录作为覆盖值。
 
 - 新建普通聊天和 Bot 模式聊天时,服务端读取该 Profile 的 `/api/config`,将 `terminal.cwd` 传给 Hermes。
 - 旧会话恢复只恢复状态;在空闲会话下一次发送消息或上传附件前,使用 `session.cwd.set` 对齐工作目录。每次新一轮执行读取当前配置,目录已经一致时不重建终端。
@@ -9,4 +9,4 @@
 - `.`、`~` 和相对路径交给实际执行的 Hermes 主机解析。需要固定目录时应在 Hermes 配置中填写该主机上的绝对路径。
 - 扫码节点和远端 Agent 在目标服务读取自己的 Profile 配置,保留授权身份;原生 Hermes Bot 入口继续由其自身管理工作区。
 
-这是夭夭 Web 服务端的规则,两端客户端无需额外获取或缓存工作路径。部署更新后的 Web 服务后,移动端通过该服务发送的下一轮消息即可使用此规则;不迁移历史文件,不修改 Hermes 全局配置。
+这是夭夭 AI 服务端的规则,两端客户端无需额外获取或缓存工作路径。部署更新后的 Web 服务后,移动端通过该服务发送的下一轮消息即可使用此规则;不迁移历史文件,不修改 Hermes 全局配置。

+ 1 - 1
docs/computer-maintenance.md

@@ -30,7 +30,7 @@ node .runner-build/runner-maintenance.mjs restore --config /安全目录/runner.
 node .runner-build/runner-maintenance.mjs rebuild --config /安全目录/runner.json --environment UUID
 ```
 
-CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的数据目录是 `~/.hermes-yaoyao/runner-state/<runnerId>`,维护时用 `--home` 显式指定该目录,并使用原始私有配置文件;App 加密配置不能直接当作 JSON 读取。
+CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的数据目录是 `~/.yaoyao/runner-state/<runnerId>`,维护时用 `--home` 显式指定该目录,并使用原始私有配置文件;App 加密配置不能直接当作 JSON 读取。
 
 备份包含工作文件及持久浏览器资料,不包含运行中的内存、进程、模型密钥、Runner 身份或整个宿主 HOME。目录设为仅当前用户访问,浏览器资料仍可能包含登录状态,应作为私有数据保存。每份备份有账号/环境归属和逐文件校验;普通文件、目录、符号链接保留,不复制运行时 socket。上限为 20 GiB、20 万项。
 

+ 3 - 3
docs/docker-install.md

@@ -1,4 +1,4 @@
-# Docker 部署夭夭 Web
+# Docker 部署夭夭 AI
 
 ## 准备
 
@@ -94,14 +94,14 @@ node yaoyao-runner/runner.mjs --config /完整路径/runner.json
 
 ## 数据与备份
 
-Compose 使用 `yaoyao-data` 命名卷,挂载到 `/var/lib/hermes-yaoyao`。账号、SQLite 数据库、聊天缓存、文件及密钥随数据卷持久保存。固定桌面部署还必须备份每台桌面的 `desktop-*-workspace` 卷;`desktop-*-ipc` 卷只是连接通道。
+Compose 使用 `yaoyao-data` 命名卷,挂载到 `/home/node/.yaoyao`。账号、SQLite 数据库、聊天缓存、文件及密钥随数据卷持久保存。固定桌面部署还必须备份每台桌面的 `desktop-*-workspace` 卷;`desktop-*-ipc` 卷只是连接通道。
 
 备份时先停止 Web,然后复制完整数据目录,再启动服务:
 
 ```sh
 mkdir -p backups
 docker compose --env-file docker.env stop web
-docker compose --env-file docker.env cp web:/var/lib/hermes-yaoyao/. ./backups/
+docker compose --env-file docker.env cp web:/home/node/.yaoyao/. ./backups/
 docker compose --env-file docker.env start web
 ```
 

+ 6 - 2
docs/github-updates.md

@@ -1,6 +1,6 @@
 # GitHub 版本升级
 
-官方发布源为 `https://github.com/Lsamien/hermes-yaoyao.git`。检查使用公开稳定 Release,不需要 GitHub 账号、访问令牌或安装 GitHub CLI;尚未发布的标签和预发布版本不会被选为更新。
+官方发布源为 `https://github.com/Lsamien/yaoyao.git`。检查使用公开稳定 Release,不需要 GitHub 账号、访问令牌或安装 GitHub CLI;尚未发布的标签和预发布版本不会被选为更新。
 
 ## Web
 
@@ -22,8 +22,12 @@ macOS 独立 Web 继续使用现有升级事务:构建新版本、等待任务
 
 ## 旧安装迁移
 
-加载新版本后,空发布源以及官方旧仓库 `git.samien.cn/samien/hermes-yaoyao`、`192.168.153.8:3000/samien/hermes-yaoyao` 的无凭据 HTTP(S) 地址会映射到 GitHub;兼容 `.git` 后缀、尾部斜杠和既有 `git@git.samien.cn:samien/hermes-yaoyao.git` 地址。其他用户自定义仓库保持原样。
+加载新版本后,空发布源、`github.com/Lsamien/hermes-yaoyao`(包括 HTTPS 与 SSH)以及官方旧仓库 `git.samien.cn/samien/hermes-yaoyao`、`192.168.153.8:3000/samien/hermes-yaoyao` 的无凭据 HTTP(S) 地址会映射到 GitHub;兼容 `.git` 后缀、尾部斜杠和既有 `git@git.samien.cn:samien/hermes-yaoyao.git` 地址。其他用户自定义仓库保持原样。
 
 运行时先使用归一化后的源;下次生成或更新本应用管理的 LaunchAgent 配置时持久化,保留地址、TLS、上游等其他环境变量。不会直接改写用户维护的环境文件。
 
 旧程序不会凭空获得迁移能力:首次需手动安装包含此功能的新 App,或更新源码并重启。既有发布标签和安装包不会被改写。
+
+从 v0.4.4 起默认应用数据为 `~/.yaoyao`,兼容读取旧配置并迁移 `~/.hermes-yaoyao`。迁移按服务所有权和 SQLite 锁检查,旧升级事务完成后才改名;上传与缓存索引、通知配置及更新记录中的内部路径一起更新。新旧目录都已有数据时不覆盖。`YAOYAO_HOME` 可覆盖默认值,既有 `HERMES_YAOYAO_HOME` 继续有效。
+
+现有 LaunchAgent 标识和 `~/.local/share/hermes-yaoyao` 程序目录用于延续服务与回滚历史,不是 Web 应用数据目录。客户端历史缓存与身份命名空间也保留兼容。

+ 4 - 4
docs/macos-app.md

@@ -1,6 +1,6 @@
 # macOS App 与本地服务
 
-App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构建首次启动时,将较旧的本机 Web 同步为配套构建;已有更新的 Web 保留。Web 由独立 LaunchAgent 运行,App 连接它。源码开发模式仍使用包内子进程。默认数据目录为 `~/.hermes-yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
+App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构建首次启动时,将较旧的本机 Web 同步为配套构建;已有更新的 Web 保留。Web 由独立 LaunchAgent 运行,App 连接它。源码开发模式仍使用包内子进程。默认数据目录为 `~/.yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
 
 ## 生命周期与权限
 
@@ -31,7 +31,7 @@ App 新增“检查 App 更新…”窗口,直接从官方 GitHub 检查稳定
 - 原有旧版服务缺少协作暂停接口时,需要确认无未完成任务及本机服务进程的客户端连接。忙碌时 App 先连接已验证的原 Web,在菜单提示同步待完成并提供重试,不强行中断聊天。不能验证身份、TLS 或数据归属的服务不接管,启动页显示错误及恢复操作。
 - 回退单独通过 Web 的回滚入口处理。只在数据库结构兼容时切回程序并保留现有数据;结构已变化或缺少兼容性记录时拒绝直接降级,不能用旧备份覆盖升级后的新消息。
 
-正式后台服务的程序位于 `~/.local/share/hermes-yaoyao/releases`,`current` 指向启用的构建;数据仍在 `~/.hermes-yaoyao`。App 签名资源不被 Web 更新器改写。开发模式的包内服务仍随开发 App 管理,不提供服务内更新。
+正式后台服务的程序位于 `~/.local/share/hermes-yaoyao/releases`,`current` 指向启用的构建;数据仍在 `~/.yaoyao`。App 签名资源不被 Web 更新器改写。开发模式的包内服务仍随开发 App 管理,不提供服务内更新。
 
 ## 构建与验收
 
@@ -40,7 +40,7 @@ npm run desktop:build
 npm run desktop:test
 npm run desktop:pack
 codesign --verify --deep --strict desktop-release/mac-arm64/夭夭.app
-hdiutil verify desktop-release/Yaoyao-0.4.3-arm64.dmg
+hdiutil verify desktop-release/Yaoyao-0.4.4-arm64.dmg
 ```
 
 开发 App 的 6 项测试通过:真实内置服务启动、账号与 Runner 连接、关闭窗口保持运行、服务崩溃恢复、外部客户端不误停服务、退出清理、加密配置再次启动、偏好保存及手动重新打开、版本不符拒绝、钥匙串失败保护。另有更新器 14 项定向测试通过,包含 App 禁用源码更新及回退。
@@ -58,4 +58,4 @@ Electron 初始化时设置其钥匙串项目名称([v43.4.0 源码](https://g
 
 最终实际签名 App 的完整 Runner 导入、加密保存、服务崩溃恢复、窗口关闭保持连接、退出清理和再次打开自动连接均已通过;另有独立的核心服务生命周期测试通过。新加密助手在实际签名 App 中验证所属路径和签名团队。安装包经挂载检查,开发专用助手没有进入分发包。
 
-v0.4.3 同时发布 Web 与 macOS Apple Silicon DMG,见 [发布说明](releases/v0.4.3.md)。正式 App 首次启动按上文同步规则升级较旧的本机 Web;已有更新版本保留。独立预览应指定独立 `HERMES_YAOYAO_HOME` 和 `HERMES_YAOYAO_DESKTOP_PORT`。
+v0.4.4 同时发布 Web 与 macOS Apple Silicon DMG,见 [发布说明](releases/v0.4.4.md)。正式 App 首次启动按上文同步规则升级较旧的本机 Web;已有更新版本保留。独立预览应指定独立 `HERMES_YAOYAO_HOME` 和 `HERMES_YAOYAO_DESKTOP_PORT`。

+ 25 - 0
docs/releases/v0.4.4.md

@@ -0,0 +1,25 @@
+# 夭夭 AI v0.4.4
+
+本次发布 Web、macOS Apple Silicon 桌面版和 Android 1.3(162)正式签名 APK。
+
+- Bot 模式桌面接管参考 OpenMausBot,打开独立的大画面窗口;支持直接键鼠操作、适应窗口、原始大小、全屏和收起的键盘面板,关闭窗口前交还控制权。
+- Web 默认数据目录改为 `~/.yaoyao`。旧版 `~/.hermes-yaoyao` 整体迁移,保留账号、会话、文件、加密密钥和执行节点状态,并迁移内部文件索引。默认新旧目录同时有数据时保留两份数据并提示处理,不自动覆盖。
+- 统一版本检查、版本比较、安装包下载与源码入口为 https://github.com/Lsamien/yaoyao 。旧官方地址自动归一化,自定义仓库保留。
+- 产品介绍统一为“夭夭 AI”,采用 Apache-2.0 开源许可证,保留第三方许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.4-arm64.dmg`:macOS Apple Silicon。使用 Apple Development 开发签名,未做 Apple 公证。
+- `Yaoyao-1.3-162-android.apk`:Android 12 及以上,使用本次新建的正式发布密钥签名。若设备上已有使用其他签名的开发版,不能直接覆盖安装。
+- `Yaoyao-0.4.4-source.tar.gz`:Web 与桌面端源码。
+- `SHA256SUMS.txt`:以上附件的 SHA-256 校验。
+
+## 升级与迁移
+
+App 首次启动等待本机服务空闲,迁移数据并同步较旧的 Web;较新的独立 Web 继续保留。Web 仍可独立升级。旧升级器的事务先在原目录完成,之后由新版本迁移助手等待空闲并重启至新目录。
+
+支持 `YAOYAO_HOME`,兼容既有 `HERMES_YAOYAO_HOME`。显式自定义目录保持不变。原有 LaunchAgent 标识、程序发布目录、浏览器缓存键和账号身份命名空间保留兼容,避免丢失已有安装或重置身份;实际默认应用数据写入 `~/.yaoyao`。
+
+Docker 继续使用原 Compose 项目和 `yaoyao-data` 命名卷,容器内数据目录改为 `/home/node/.yaoyao`。重新构建并启动 Compose 即可复用原卷。不要执行删除数据卷的操作。
+
+迁移后本地虚拟机按新目录重新挂载持久工作区和浏览器资料。原来的已停止容器保留为带 `before-data-move` 后缀的备份,未自动删除其可写层。

+ 8 - 0
docs/verification/2026-09-10-v044/README.md

@@ -0,0 +1,8 @@
+# v0.4.4 验证
+
+- 真实 Electron 独立窗口:本机测试服务授予控制租约,画面输入由服务器接收,系统关闭按钮先交还租约,主窗口保留。截图为交互测试画面,不表示真实虚拟机业务任务已完成。
+- 数据迁移:覆盖旧默认目录、自定义目录、SQLite 活跃锁、账号密钥、Runner 状态、文件索引、更新事务延后、冲突保留和改名后的恢复。
+- 已停止虚拟机旧挂载:验证所属节点、完整隔离配置与旧路径后保留原容器,重新挂载新目录下的工作文件。
+- 本机完整数据备份后迁移成功:247 条工作区实体、240 条缓存消息,三个数据库完整性检查通过,账号和密钥摘要保持一致。
+- Android 1.3(162)Release 构建与单元测试通过,APK 正式签名验证通过。
+- 最终 Web 回归:897 通过、7 跳过;类型检查与生产构建通过。Electron 生命周期/升级等 13 项、原生钥匙串 1 项和独立接管窗口 1 项通过;Bot 侧栏桌面/手机浏览器流程 1 项通过。

BIN
docs/verification/2026-09-10-v044/computer-takeover.png


+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
     <meta name="theme-color" content="#fafafa" />
-    <meta name="description" content="夭夭 Web:Hermes 聊天、团队、文件库与产物工作台" />
+    <meta name="description" content="夭夭 AI:Hermes 聊天、团队、文件库与产物工作台" />
     <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
     <link rel="manifest" href="/manifest.webmanifest" />

+ 6 - 5
package-lock.json

@@ -1,13 +1,13 @@
 {
-  "name": "hermes-yaoyao",
-  "version": "0.4.3",
+  "name": "yaoyao",
+  "version": "0.4.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "hermes-yaoyao",
-      "version": "0.4.3",
-      "license": "BSL-1.1",
+      "name": "yaoyao",
+      "version": "0.4.4",
+      "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
         "@koa/router": "^15.4.0",
@@ -50,6 +50,7 @@
         "zod": "^4.1.12"
       },
       "bin": {
+        "yaoyao": "bin/hermes-yaoyao.mjs",
         "hermes-yaoyao": "bin/hermes-yaoyao.mjs"
       },
       "devDependencies": {

+ 12 - 6
package.json

@@ -1,16 +1,17 @@
 {
-  "name": "hermes-yaoyao",
-  "version": "0.4.3",
+  "name": "yaoyao",
+  "version": "0.4.4",
   "private": true,
-  "license": "BSL-1.1",
-  "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",
+  "license": "Apache-2.0",
+  "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",
   "type": "module",
   "main": "desktop/main.mjs",
   "engines": {
     "node": ">=24.0.0"
   },
   "bin": {
-    "hermes-yaoyao": "./bin/hermes-yaoyao.mjs"
+    "hermes-yaoyao": "./bin/hermes-yaoyao.mjs",
+    "yaoyao": "./bin/hermes-yaoyao.mjs"
   },
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
@@ -89,5 +90,10 @@
     "supertest": "^7.1.4",
     "tsx": "^4.21.0",
     "vitest": "^3.2.4"
-  }
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/Lsamien/yaoyao.git"
+  },
+  "homepage": "https://github.com/Lsamien/yaoyao"
 }

+ 1 - 1
public/manifest.webmanifest

@@ -1,5 +1,5 @@
 {
-  "name": "夭夭 Web",
+  "name": "夭夭 AI",
   "short_name": "夭夭",
   "description": "Hermes 聊天、群聊、文件库与产物工作台",
   "start_url": "/",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.3",
-  "webVersion": "0.4.3",
-  "gitTag": "v0.4.3"
+  "releaseVersion": "0.4.4",
+  "webVersion": "0.4.4",
+  "gitTag": "v0.4.4"
 }

+ 7 - 5
scripts/build-desktop.mjs

@@ -25,8 +25,9 @@ await cp(resolve(root, 'release.json'), resolve(out, 'release.json'))
 await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon.png'))
 const shell = resolve(out, 'shell')
 await mkdir(shell)
+await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
 for (const file of await readdir(resolve(root, 'desktop'))) {
-  if (file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
+  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
   if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }
@@ -36,13 +37,14 @@ const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'
 const service = resolve(out, 'web-service')
 await mkdir(service)
 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 })
+for (const file of ['LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'licenses']) await cp(resolve(root, file), resolve(service, file), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(await realpath(process.execPath), resolve(service, 'node'))
 await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))
 await chmod(resolve(service, 'node'), 0o755)
-await writeFile(resolve(service, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao', version: manifest.webVersion, type: 'module' }))
+await writeFile(resolve(service, 'package.json'), JSON.stringify({ name: 'yaoyao', version: manifest.webVersion, type: 'module' }))
 sealRuntime(service)
-await writeFile(resolve(shell, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao-desktop', version: manifest.webVersion,
-  productName: '夭夭', description: '夭夭:本地服务、多 Agent 团队与电脑工作空间',
-  author: 'YaoYao contributors', license: 'BSL-1.1', main: 'main.mjs', type: 'module' }, null, 2))
+await writeFile(resolve(shell, 'package.json'), JSON.stringify({ name: 'yaoyao-desktop', version: manifest.webVersion,
+  productName: '夭夭', description: '夭夭 AI:本地服务、多 Agent 团队与电脑工作空间',
+  author: 'YaoYao contributors', license: 'Apache-2.0', main: 'main.mjs', type: 'module' }, null, 2))
 console.log('桌面服务和界面已打包到 .desktop-build')

+ 1 - 1
scripts/generate-desktop-compose.mjs

@@ -9,7 +9,7 @@ const q=JSON.stringify
 const targets=entries.map(d=>({id:d.id,name:d.name,socketPath:`/run/yaoyao-desktops/${d.service}/desktop.sock`}))
 const base=readFileSync(resolve(root,'compose.yaml'),'utf8')
   .replace('      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"','      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"\n      HERMES_YAOYAO_COMPOSE_DESKTOPS: '+q(JSON.stringify(targets)))
-  .replace('      - yaoyao-data:/var/lib/hermes-yaoyao','      - yaoyao-data:/var/lib/hermes-yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro`).join('\n'))
+  .replace('      - yaoyao-data:/home/node/.yaoyao','      - yaoyao-data:/home/node/.yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro`).join('\n'))
 const volumesAt=base.indexOf('\nvolumes:')
 if(volumesAt<0||!base.includes('HERMES_YAOYAO_COMPOSE_DESKTOPS'))throw new Error('compose.yaml 模板结构已变化')
 let yaml='# Generated from compose.yaml and deploy/compose-desktops.json.\n'+base.slice(0,volumesAt)+'\n'

+ 2 - 2
src/client/App.vue

@@ -24,7 +24,7 @@ const agentIdentityFixture = import.meta.env.DEV
   && new URLSearchParams(window.location.search).get('fixture') === 'agent-identity'
 
 const pageTitle = computed(() => {
-  const name = route.path.startsWith('/history')
+  const name = route.name === 'computer' ? '电脑接管' : route.path.startsWith('/history')
     ? '历史记录'
     : route.path.startsWith('/conversations')
     ? '聊天'
@@ -33,7 +33,7 @@ const pageTitle = computed(() => {
     : route.path.startsWith('/files')
       ? '文件库'
       : chat.activeSession?.title || '对话'
-  return `${name} · 夭夭`
+  return `${name} · 夭夭 AI`
 })
 
 watch(() => theme.resolvedTheme, value => {

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

@@ -95,7 +95,7 @@ onMounted(()=>{void action(refresh)})
       <p>配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:</p>
       <code v-if="bundleAvailable">mkdir -p yaoyao-runner<br>tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner<br>chmod 600 runner.json<br>node yaoyao-runner/runner.mjs --config /完整路径/runner.json</code>
       <code v-else>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
-      <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 hermes-yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>
+      <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>
     </div>
     <p v-if="error" class="runner-error" role="alert">{{ error }}</p>
     <p v-if="notice" role="status">{{ notice }}</p>

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

@@ -391,13 +391,13 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
       :inert="mobileDrawerOpen"
     >
       <div v-if="applicationWorkspace" class="bot-list-header">
-        <button class="bot-logo-trigger sidebar-brand" type="button" aria-label="返回 Bot 列表" title="夭夭 Web" @click="navigate('/conversations')"><BrandMark :size="32" :label="false" compact bare /></button>
+        <button class="bot-logo-trigger sidebar-brand" type="button" aria-label="返回 Bot 列表" title="夭夭 AI" @click="navigate('/conversations')"><BrandMark :size="32" :label="false" compact bare /></button>
         <span class="bot-list-header__spacer" />
         <button class="bot-list-toolbar-button sidebar-search-trigger" type="button" aria-label="搜索" :aria-expanded="sidebarSearchOpen" @click="openSidebarSearch(desktopSidebarContext)"><AppIcon name="search" :size="21" /></button>
         <button class="bot-list-toolbar-button sidebar-create-trigger" type="button" aria-label="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="23" /></button>
       </div>
       <div v-else class="sidebar-brand-row">
-        <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 Web" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
+        <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 AI" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </button>
       </div>

+ 1 - 1
src/client/components/common/BrandMark.vue

@@ -17,7 +17,7 @@ withDefaults(defineProps<{
     <span class="brand-mark__plate" :style="{ width: `${size}px`, height: `${size}px` }">
       <img src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
     </span>
-    <span v-if="label" class="brand-mark__name">夭夭 Web</span>
+    <span v-if="label" class="brand-mark__name">夭夭 AI</span>
   </span>
 </template>
 

+ 32 - 16
src/client/components/workspace/ComputerPanel.vue

@@ -7,10 +7,10 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
 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 controlsOpen=ref(false),browserState=ref<BotBrowserState>()
+const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
@@ -64,31 +64,38 @@ async function flushInputs(){
   finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
 }
 function browserAction(action:Record<string,string>){if(!canSend.value||!frame.value||!ticket.value)return;const f=frame.value;void run(async()=>{await post('/browser',{...ticket.value,requestId:createUuid(),generation:f.generation,frameId:f.id,action})})}
-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)))}}
+function point(event:MouseEvent|PointerEvent){
+  const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!
+  const scale=Math.min(r.width/f.width,r.height/f.height),left=r.left+(r.width-f.width*scale)/2,top=r.top+(r.height-f.height*scale)/2
+  const x=Math.floor((event.clientX-left)/scale),y=Math.floor((event.clientY-top)/scale)
+  if(x<0||y<0||x>=f.width||y>=f.height)return
+  return {x,y}
+}
 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 down(event:PointerEvent){if(!canSend.value||event.button!==0)return;(event.currentTarget as HTMLElement).focus({preventScroll:true});const p=point(event);if(!p)return;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);if(!end)return;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){
   if(dragged){dragged=false;return}
   if(!canSend.value||!frame.value)return
-  const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
-  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}
+  const p=point(event);if(!p)return
+  const action:ComputerInput={kind:'click',...p,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)}
 }
 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;if(!props.embedded)dialog.value?.close();emit('close')}}
+async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded&&!props.standalone)dialog.value?.close();emit('close')}}
+async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await dialog.value?.requestFullscreen()}catch{error.value='无法进入全屏,请使用窗口最大化'}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
 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()})
+onMounted(()=>{if(!props.embedded&&!props.standalone)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})
+defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
 </script>
 <template>
-  <Teleport to="body" :disabled="embedded">
-    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" :aria-label="state.backend==='local'?'本机':state.backend==='browser'?'浏览器':'隔离电脑'" @cancel.prevent="close">
+  <Teleport to="body" :disabled="embedded||standalone">
+    <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="state.backend==='local'?'本机':state.backend==='browser'?'浏览器':'隔离电脑'" @cancel.prevent="close">
       <header>
         <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?'本机 · '+state.hostName:state.backend==='browser'?'浏览器 · '+state.hostName:'隔离电脑'}}</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>
@@ -96,22 +103,27 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
         <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>
+        <template v-if="!embedded">
+          <button v-if="mine" type="button" :aria-expanded="controlsOpen" @click="controlsOpen=!controlsOpen">键盘与操作</button>
+          <button type="button" :aria-pressed="actualSize" @click="actualSize=!actualSize">{{actualSize?'适应窗口':'原始大小'}}</button>
+          <button type="button" @click="fullscreen">全屏</button>
+        </template>
+        <button v-if="!embedded" class="return-control" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
       </header>
       <BrowserToolbar v-if="state.backend==='browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
-      <div class="computer-screen" :class="{controlling:canSend}">
+      <div class="computer-screen" :class="{controlling:canSend,'actual-size':actualSize}">
         <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="state.backend==='local'?'本机当前画面':state.backend==='browser'?'浏览器当前画面':'隔离电脑当前画面'" 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>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
       <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
-      <footer v-if="!embedded||controlsOpen">
+      <footer v-if="controlsOpen||(!embedded&&!ticket)">
         <p v-if="state.backend==='grok'">多个机器人共享同一台 Grok Bot 云端电脑、工作文件与浏览器资料。</p>
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
         <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
         <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
-          <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
+
           <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 :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:[state.backend==='local'||state.backend==='browser'?'super':'ctrl']})">粘贴</button></div>
@@ -129,6 +141,10 @@ defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)
 <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.standalone{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;margin:0;border:0;border-radius:0;overflow:hidden}
+.standalone .computer-screen{flex:1;min-height:0;overflow:hidden}.standalone .computer-screen img{height:100%;width:100%;max-height:100%;max-width:100%;object-fit:contain}.standalone header{flex-shrink:0;background:#1c1f23;color:#f4f4f5}.standalone header small{color:#bec4cc}.standalone header button,.standalone header select{background:#2b2f34;color:#f4f4f5;border-color:#474c54}.standalone header .return-control{background:#f4f4f5;color:#202327;border-color:#f4f4f5}.standalone footer{position:absolute;right:16px;top:78px;width:min(360px,calc(100vw - 64px));max-height:calc(100dvh - 130px);overflow:auto;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 50px #0006;z-index:2}
+.computer-panel:fullscreen{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;border:0;border-radius:0}.computer-panel:fullscreen .computer-screen{flex:1;min-height:0}.computer-panel:fullscreen .computer-screen img{max-height:100%}
+.computer-screen.actual-size{overflow:auto;align-items:flex-start;justify-content:flex-start}.computer-screen.actual-size img{width:auto;height:auto;max-width:none;max-height:none;flex:none}.return-control{font-weight:600}.standalone .computer-error{flex:none;margin:0;padding:10px 18px}
 .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}
 .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}

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

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

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

@@ -8,3 +8,11 @@ interface ImportMetaEnv {
 interface ImportMeta {
   readonly env: ImportMetaEnv
 }
+
+interface Window {
+  yaoyaoDesktop?: {
+    openComputer(id: string): Promise<boolean>
+    computerClosed(): Promise<void>
+    onComputerClose(callback: () => void): () => void
+  }
+}

+ 2 - 1
src/client/router/index.ts

@@ -7,6 +7,7 @@ const router = createRouter({
     { path: '/', redirect: () => savedInterfacePath() },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
     { path: '/history/:sessionId?', name: 'history', component: () => import('@/views/HistoryView.vue') },
+    { path: '/conversations/computer/:agentId', name: 'computer', component: () => import('@/views/ComputerViewerView.vue') },
     { path: '/conversations/:id?', name: 'conversations', component: () => import('@/views/ConversationsView.vue') },
     { path: '/kanban/:boardSlug?', name: 'kanban', component: () => import('@/views/KanbanView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },
@@ -15,6 +16,6 @@ const router = createRouter({
   ],
   scrollBehavior: () => ({ top: 0 }),
 })
-router.afterEach(to => rememberInterfacePath(to.path))
+router.afterEach(to => { if (to.name !== 'computer') rememberInterfacePath(to.path) })
 
 export default router

+ 30 - 0
src/client/views/ComputerViewerView.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { apiRequest } from '@/api/client'
+import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
+import type { WorkspaceAgent } from '@shared/workspace'
+const route = useRoute(), agent = ref<WorkspaceAgent>(), error = ref(''), panel = ref<InstanceType<typeof ComputerPanel>>()
+let detach: (() => void) | undefined
+async function finish(){await window.yaoyaoDesktop?.computerClosed()}
+async function close() {
+  if (panel.value) await panel.value.close()
+  else await window.yaoyaoDesktop?.computerClosed()
+}
+onMounted(async () => {
+  detach = window.yaoyaoDesktop?.onComputerClose(() => { void close() })
+  try {
+    const result = await apiRequest<{agents: WorkspaceAgent[]}>('/api/app/agents')
+    agent.value = result.agents.find(item => item.id === route.params.agentId)
+    if (!agent.value) throw new Error('这台电脑不可用,或你已没有访问权限')
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '无法打开电脑' }
+})
+onBeforeUnmount(() => detach?.())
+</script>
+<template>
+  <ComputerPanel v-if="agent" ref="panel" :agents="[agent]" standalone auto-take @close="finish" />
+  <main v-else class="viewer-loading" role="status"><p>{{ error || '正在连接电脑…' }}</p><button v-if="error" @click="close">关闭窗口</button></main>
+</template>
+<style scoped>
+.viewer-loading{height:100dvh;display:grid;place-content:center;background:#111315;color:#f4f4f4;text-align:center}.viewer-loading button{padding:12px;border-radius:8px;border:1px solid #666;background:#292c30;color:inherit}
+</style>

+ 5 - 1
src/client/views/ConversationsView.vue

@@ -192,6 +192,10 @@ const selected = computed(() => (typeof route.params.id === 'string' ? route.par
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
 const computerOpen=ref(false),computerDockOpen=ref(false),twoDesktops=ref(false),desktopAgent=ref<Agent>()
+async function openComputer(agent:Agent){
+  if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id)}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
+  desktopAgent.value=agent;computerOpen.value=true
+}
 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}}
@@ -732,7 +736,7 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
-    <WorkspaceBotPanel v-if="computerDockOpen&&active" :conversation-id="active.id" :task-id="activeTask?.id" :group="active.kind==='group'" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
+    <WorkspaceBotPanel v-if="computerDockOpen&&active" :conversation-id="active.id" :task-id="activeTask?.id" :group="active.kind==='group'" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="openComputer" @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" />

+ 19 - 4
src/runner/computers/container.ts

@@ -1,7 +1,7 @@
 import { createHash, randomBytes } from 'node:crypto'
 import { spawn } from 'node:child_process'
-import { mkdir, realpath, chmod, lstat, rm } from 'node:fs/promises'
-import { join, resolve, posix } from 'node:path'
+import { mkdir, realpath, chmod, lstat, rm, readFile } from 'node:fs/promises'
+import { join, resolve, posix, dirname, basename } from 'node:path'
 import { z } from 'zod'
 import {recoverWorkspace} from './workspaceRecovery.js'
 
@@ -126,9 +126,24 @@ export class ContainerComputerProvider implements ComputerProvider {
     if(Object.values(detail.NetworkSettings?.Ports??{}).some(bindings=>Array.isArray(bindings)&&bindings.length))throw new ComputerError('computer_viewer_unsafe','电脑环境不能发布网络端口')
     return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
   }
+  private async migratedContainer(spec:Spec,detail:any,workspace:string) {
+    if(!detail || detail.Mounts?.every((mount:any)=>mount.Source===workspace))return detail
+    let root=resolve(this.home)
+    while(dirname(root)!==root&&basename(root)!=='.yaoyao')root=dirname(root)
+    if(basename(root)!=='.yaoyao')return detail
+    let migration
+    try{migration=JSON.parse(await readFile(join(root,'.data-home-migration.json'),'utf8'))}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return detail;throw error}
+    if(migration.phase!=='complete'||migration.target!==root||migration.source!==join(dirname(root),'.hermes-yaoyao'))return detail
+    const oldWorkspace=migration.source+workspace.slice(root.length)
+    this.assertConfiguration(spec,detail,oldWorkspace)
+    if(detail.State?.Running)throw new ComputerError('computer_migration_busy','旧目录的虚拟机仍在运行,请停止后再迁移')
+    // Preserve the old writable layer as a stopped backup; only the verified name changes.
+    await this.run(this.runtime,['rename',detail.Id,`${this.name(spec)}-before-data-move-${detail.Id.slice(0,8)}`])
+    return undefined
+  }
   async inspect(value:ComputerSpecification):Promise<ComputerState|undefined> {
     await this.verifyRuntime()
-    const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.inspectRaw(this.name(spec))
+    const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
     return detail?this.state(spec,detail,workspace):undefined
   }
   private args(spec:Spec,workspace:string) {
@@ -148,7 +163,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     return this.serial(spec,async()=>{
       authorize();await this.verifyRuntime();authorize()
       const workspace=await this.workspace(spec)
-      let detail=await this.inspectRaw(this.name(spec))
+      let detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
       if(!detail) {
         const image=JSON.parse((await this.run(this.runtime,['image','inspect',spec.imageId])).stdout)[0]
         if(image?.Id!==spec.imageId||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')

+ 2 - 1
src/server/config.ts

@@ -1,5 +1,6 @@
 import { isIP } from 'node:net'
 import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
+import { resolveDataHome } from '../../bin/lib/data-home.mjs'
 import { homedir } from 'node:os'
 import { basename, resolve } from 'node:path'
 import { readFileSync } from 'node:fs'
@@ -155,7 +156,7 @@ export function loadServerConfig(
   const host = env.HERMES_YAOYAO_HOST?.trim() || '127.0.0.1'
   const port = parsePort(env.HERMES_YAOYAO_PORT)
   const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
-  const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
+  const home = resolveDataHome(env.YAOYAO_HOME || env.HERMES_YAOYAO_HOME, { preserveActiveUpdate: true })
   const mediaRoot = resolve(env.HERMES_YAOYAO_MEDIA_ROOT?.trim() || `${homedir()}/Agents`)
   const attachmentsRoot = resolve(`${homedir()}/.hermes/attachments`)
   const imagesRoot = resolve(`${homedir()}/.hermes/images`)

+ 12 - 3
src/server/index.ts

@@ -9,8 +9,17 @@ import { isLocalAuthorizationTarget } from './loopbackAuthorization.js'
 import { acquireServiceInstance } from './serviceInstance.js'
 import { readReleaseManifest } from './releases.js'
 import { readBuildIdentity } from './buildIdentity.js'
+import { migrateDataHome } from '../../bin/lib/data-migration.mjs'
+import { legacyDataHome, resolveDataHome } from '../../bin/lib/data-home.mjs'
+import { spawn } from 'node:child_process'
 
+migrateDataHome(resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME, { preserveActiveUpdate: true }))
 const config = loadServerConfig()
+if (config.home === legacyDataHome() && process.platform === 'darwin') {
+  const child = spawn(process.execPath, [resolve(process.cwd(), 'bin/migrate-data-home.mjs'), String(config.port)], { detached: true, stdio: 'ignore' })
+  child.on('error', error => console.error('数据迁移助手启动失败:', error.message))
+  child.unref()
+}
 const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1', readBuildIdentity(process.cwd()))
 process.once('exit', () => instance.release())
 const runtime = createApplication({
@@ -73,15 +82,15 @@ if (runtime.config.production) {
 
 nodeRuntime.server.once('error', (error: NodeJS.ErrnoException) => {
   if (error.code === 'EADDRINUSE') {
-    console.error(`夭夭 Web could not start: ${runtime.config.host}:${runtime.config.port} is already in use`)
+    console.error(`夭夭 AI could not start: ${runtime.config.host}:${runtime.config.port} is already in use`)
   } else {
-    console.error(`夭夭 Web could not start: ${error.message}`)
+    console.error(`夭夭 AI could not start: ${error.message}`)
   }
   void shutdown().finally(() => { process.exitCode = 1 })
 })
 nodeRuntime.server.listen(runtime.config.port, runtime.config.host, () => {
   const protocol = runtime.config.tlsCert ? 'https' : 'http'
-  console.log(`夭夭 Web listening on ${protocol}://${runtime.config.host}:${runtime.config.port}`)
+  console.log(`夭夭 AI listening on ${protocol}://${runtime.config.host}:${runtime.config.port}`)
   instance.publish(`${protocol}://${runtime.config.host}:${runtime.config.port}`)
   if (runtime.config.insecureLan) {
     console.warn('Warning: trusted-LAN HTTP mode is enabled; credentials are not encrypted in transit.')

+ 2 - 2
tests/client/systemUpdatePanel.test.ts

@@ -79,14 +79,14 @@ describe('SystemUpdatePanel', () => {
     wrapper.unmount()
   })
   it('shows GitHub failures without claiming the installed version is latest', async () => {
-    api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, releaseSource: 'https://github.com/Lsamien/hermes-yaoyao.git', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases' })
+    api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, releaseSource: 'https://github.com/Lsamien/yaoyao.git', releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases' })
     api.checkSystemUpdate.mockRejectedValue(new Error('GitHub 请求受限,请稍后重试'))
     const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
     await flushPromises()
     expect(wrapper.text()).toContain('GitHub 请求受限')
     expect(wrapper.text()).not.toContain('已是最新版本')
     expect(wrapper.get('.solid-button').attributes('disabled')).toBeDefined()
-    expect(wrapper.get('a').attributes('href')).toBe('https://github.com/Lsamien/hermes-yaoyao/releases')
+    expect(wrapper.get('a').attributes('href')).toBe('https://github.com/Lsamien/yaoyao/releases')
     wrapper.unmount()
   })
   it('allows Web updates with unknown plugin state and explains the offline boundary', async () => {

+ 8 - 8
tests/e2e/workspace.spec.ts

@@ -32,7 +32,7 @@ test('separates writable Web chats from read-only Hermes history', async ({ page
   await expect(page).toHaveURL(/\/history/)
   await expect(page.getByText('选择历史记录', { exact: true })).toBeVisible()
   await expect(sidebar.getByText('Hermes 外部历史', { exact: true })).toBeVisible()
-  await expect(sidebar.getByText('夭夭 Web 验收会话', { exact: true })).toHaveCount(0)
+  await expect(sidebar.getByText('夭夭 AI 验收会话', { exact: true })).toHaveCount(0)
   await sidebar.getByText('Hermes 外部历史', { exact: true }).click()
   await expect(page).toHaveURL(/\/history\/session-history-only/)
   await expect(page.locator('.composer-shell')).toHaveCount(0)
@@ -86,7 +86,7 @@ test.skip('navigates every 9119 workspace without blank transitions', async ({ p
   const consoleErrors: string[] = []
   page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) })
 
-  await expect(page.getByRole('option').first()).toContainText('夭夭 Web 验收会话')
+  await expect(page.getByRole('option').first()).toContainText('夭夭 AI 验收会话')
   await expect(page.getByRole('option').first()).toContainText('夭夭')
   await expect(page.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toBeVisible()
   await page.getByRole('button', { name: '团队' }).click()
@@ -142,7 +142,7 @@ test.skip('navigates every 9119 workspace without blank transitions', async ({ p
 
 test.skip('uses the active session or group title in the browser title', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
-  await expect(page).toHaveTitle('夭夭 Web 验收会话 · 夭夭')
+  await expect(page).toHaveTitle('夭夭 AI 验收会话 · 夭夭')
 
   await page.goto('/groups/group-demo')
   await expect(page).toHaveTitle('设计与工程协作 · 夭夭')
@@ -337,7 +337,7 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
             size: 48,
             modifiedAt: Date.now() / 1000,
             exists: true,
-            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 AI 验收会话' }],
           }, {
             id: 'empty-markdown-file',
             path: '/tmp/空白说明.md',
@@ -347,7 +347,7 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
             size: 0,
             modifiedAt: Date.now() / 1000,
             exists: true,
-            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 AI 验收会话' }],
           }],
           nextCursor: null,
           total: 2,
@@ -401,7 +401,7 @@ test('keeps DOCX body at 36px inset with zero canvas margin', async ({ page }) =
             size: 1024,
             modifiedAt: Date.now() / 1000,
             exists: true,
-            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 AI 验收会话' }],
           }],
           nextCursor: null,
           total: 1,
@@ -491,7 +491,7 @@ test('keeps pins first and loads the next session page at the list bottom', asyn
   const sidebar = page.locator('.desktop-sidebar')
   const items = sidebar.locator('.sidebar-list [role="option"]')
   await expect(items).toHaveCount(100)
-  await expect(items.first()).toContainText('夭夭 Web 验收会话')
+  await expect(items.first()).toContainText('夭夭 AI 验收会话')
   await expect(sidebar.getByRole('button', { name: '继续加载会话' })).toBeVisible()
 
   const list = sidebar.locator('.sidebar-list')
@@ -989,7 +989,7 @@ test('uses the unified Grok-style sidebar, search trigger, and persistent collap
   await expect(sidebar.locator('.sidebar-search')).toHaveCount(0)
   const originalSessionCount = await sidebar.getByRole('option').count()
   await search.fill('验收')
-  const sessionResult = searchDialog.getByRole('option', { name: /夭夭 Web 验收会话/ })
+  const sessionResult = searchDialog.getByRole('option', { name: /夭夭 AI 验收会话/ })
   await expect(sessionResult).toBeVisible()
   await expect(sidebar.getByRole('option')).toHaveCount(originalSessionCount)
   await sessionResult.click()

+ 2 - 2
tests/fixtures/fake-hermes.mjs

@@ -107,7 +107,7 @@ const sessions = [
   // intentionally absent from the Web user's registry and must stay history-only.
   { id: 'session-history-only', profile: 'yaoyao', source: 'web', title: 'Hermes 外部历史', preview: '只读历史记录', message_count: 1, tool_call_count: 0, started_at: now() - 1800, last_active_at: now() - 900, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
-  { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
+  { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 AI 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-media', profile: 'yaoer', source: 'web', title: '瑶儿生成图片验收', message_count: 1, tool_call_count: 0, started_at: now() - 3400, last_active_at: now() - 30, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-user-media', profile: 'yaoer', source: 'web', title: '用户多图验收', message_count: 1, tool_call_count: 0, started_at: now() - 3300, last_active_at: now() - 40, model: 'gpt-5.6', provider: 'openai' },
@@ -453,7 +453,7 @@ const server = createServer(async (request, response) => {
     const items = selectedTopicId ? groupMessages.filter(message => message.topicId === selectedTopicId) : groupMessages
     return json(response, 200, { items })
   }
-  if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
+  if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 AI 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/1/download') {
     response.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': previewPdf.length })
     return response.end(previewPdf)

+ 24 - 2
tests/server/computerProvider.test.ts

@@ -1,14 +1,14 @@
 // @vitest-environment node
 import {afterEach,expect,it} from 'vitest'
 import {randomUUID} from 'node:crypto'
-import {mkdtemp,rm,symlink,mkdir} from 'node:fs/promises'
+import {mkdtemp,rm,symlink,mkdir,rename,writeFile,realpath} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {ContainerComputerProvider,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
 const homes:string[]=[]
 afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
 async function fixture(runtime:'docker'|'podman'='docker'){
-  const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-'));homes.push(home)
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-')));homes.push(home)
   const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:`sha256:${'a'.repeat(64)}`}
   let detail:any
   const calls:string[][]=[]
@@ -89,3 +89,25 @@ it('checks authority again after asynchronous inspection, before a guest command
   await expect(provider.execute(f.spec,['true'],{authorize:()=>{if(!active)throw new Error('revoked')}})).rejects.toThrow('revoked')
   expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
 })
+
+it('recreates a migrated mount while retaining the stopped original container and persistent workspace',async()=>{
+  const f=await fixture(),oldRoot=join(f.home,'.hermes-yaoyao'),newRoot=join(f.home,'.yaoyao')
+  const oldHome=join(oldRoot,'runner-state/r1'),newHome=join(newRoot,'runner-state/r1')
+  await mkdir(oldHome,{recursive:true})
+  const provider=new ContainerComputerProvider('docker',f.provider.runnerId,oldHome,f.run,{})
+  await provider.ensure(f.spec,()=>{});await provider.stop(f.spec)
+  await writeFile(join(oldHome,'computer-workspaces',f.spec.id,'keep.txt'),'keep workspace')
+  await rename(oldRoot,newRoot)
+  await writeFile(join(newRoot,'.data-home-migration.json'),JSON.stringify({source:oldRoot,target:newRoot,phase:'complete'}))
+  let renamed=false
+  const next=new ContainerComputerProvider('docker',f.provider.runnerId,newHome,async(runtime,args,options)=>{
+    if(args[0]==='rename'){renamed=true;expect(args[1]).toBe(f.detail.Id);expect(args[2]).toContain('-before-data-move-');return {stdout:'',stderr:''}}
+    if(args[0]==='inspect'&&renamed&&args[1]!==f.detail.Id)throw Object.assign(new Error('missing'),{stderr:'No such object'})
+    if(args[0]==='create')renamed=false
+    return f.run(runtime,args,options)
+  },{})
+  const state=await next.ensure(f.spec,()=>{})
+  expect(state.workspace).toContain('/.yaoyao/')
+  await expect((await import('node:fs/promises')).readFile(join(state.workspace,'keep.txt'),'utf8')).resolves.toBe('keep workspace')
+  expect(f.calls.filter(args=>args[0]==='rm')).toHaveLength(0)
+})

+ 73 - 0
tests/server/dataMigration.test.ts

@@ -0,0 +1,73 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { resolveDataHome } from '../../bin/lib/data-home.mjs'
+import { migrateDataHome } from '../../bin/lib/data-migration.mjs'
+const roots: string[] = []
+function fixture() {
+  const userHome = mkdtempSync(join(tmpdir(), 'yaoyao-migration-')); roots.push(userHome)
+  const old = join(userHome, '.hermes-yaoyao'), home = join(userHome, '.yaoyao')
+  mkdirSync(old, { mode: 0o700 })
+  return { userHome, old, home }
+}
+afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
+describe('data directory migration', () => {
+  it('normalizes the old default and preserves explicit custom homes', () => {
+    const { userHome, old, home } = fixture()
+    expect(resolveDataHome(undefined, { userHome })).toBe(home)
+    expect(resolveDataHome(old, { userHome })).toBe(home)
+    expect(resolveDataHome('/srv/custom', { userHome })).toBe('/srv/custom')
+  })
+  it('moves accounts, binary keys, WAL data, Runner state and files and rebases only internal paths', () => {
+    const { userHome, old, home } = fixture(), key = Buffer.from([0, 23, 255, 8])
+    writeFileSync(join(old, 'workspace-key.bin'), key)
+    writeFileSync(join(old, 'users.json'), '{"id":"same-user"}')
+    mkdirSync(join(old, 'runner-state', 'runner'), { recursive: true })
+    writeFileSync(join(old, 'runner-state', 'runner', 'config.enc'), key)
+    mkdirSync(join(old, 'uploads')); writeFileSync(join(old, 'uploads', 'example.txt'), 'file-content')
+    mkdirSync(join(old, 'updates')); writeFileSync(join(old, 'updates', 'last-success.json'), JSON.stringify({snapshot: old + '/updates/backups/1', previous: {plist: {EnvironmentVariables: {HERMES_YAOYAO_HOME: old}}}}))
+    const db = new DatabaseSync(join(old, 'uploads.sqlite3'))
+    db.exec('PRAGMA journal_mode=WAL; CREATE TABLE uploads(id TEXT, path TEXT, message TEXT)')
+    db.prepare('INSERT INTO uploads VALUES(?,?,?)').run('1', old + '/uploads/example.txt', old + '/leave-message-text')
+    db.prepare('INSERT INTO uploads VALUES(?,?,?)').run('2', old + '-other/file', 'external')
+    db.close()
+    expect(migrateDataHome(home, { userHome })).toBe(true)
+    expect(existsSync(old)).toBe(false)
+    expect(readFileSync(join(home, 'workspace-key.bin'))).toEqual(key)
+    expect(readFileSync(join(home, 'runner-state/runner/config.enc'))).toEqual(key)
+    expect(readFileSync(join(home, 'users.json'), 'utf8')).toContain('same-user')
+    const moved = new DatabaseSync(join(home, 'uploads.sqlite3'))
+    expect(moved.prepare('SELECT * FROM uploads ORDER BY id').all()).toEqual([
+      { id: '1', path: home + '/uploads/example.txt', message: old + '/leave-message-text' },
+      { id: '2', path: old + '-other/file', message: 'external' },
+    ])
+    expect(moved.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }); moved.close()
+    expect(JSON.parse(readFileSync(join(home, 'updates/last-success.json'), 'utf8')).snapshot).toBe(home + '/updates/backups/1')
+    expect(migrateDataHome(home, { userHome })).toBe(false)
+  })
+  it('refuses live service or Runner locks without moving either directory', () => {
+    const { userHome, old, home } = fixture()
+    mkdirSync(join(old, 'runner-state/r1'), { recursive: true })
+    const db = new DatabaseSync(join(old, 'runner-state/r1/service-instance.sqlite3')); db.exec('BEGIN EXCLUSIVE')
+    try { expect(() => migrateDataHome(home, { userHome })).toThrow('使用'); expect(existsSync(old)).toBe(true); expect(existsSync(home)).toBe(false) }
+    finally { db.close() }
+  })
+  it('preserves both populated directories rather than overwriting a newer installation', () => {
+    const { userHome, old, home } = fixture(); mkdirSync(home)
+    writeFileSync(join(old, 'users.json'), 'old'); writeFileSync(join(home, 'users.json'), 'new')
+    expect(() => migrateDataHome(home, { userHome })).toThrow('同时存在')
+    expect(readFileSync(join(old, 'users.json'), 'utf8')).toBe('old'); expect(readFileSync(join(home, 'users.json'), 'utf8')).toBe('new')
+  })
+  it('defers old updater transactions and resumes a crash after the directory rename', () => {
+    const { userHome, old, home } = fixture(); mkdirSync(join(old, 'updates'))
+    writeFileSync(join(old, 'updates/active.lock'), JSON.stringify({ pid: process.pid }))
+    expect(resolveDataHome(undefined, { userHome, preserveActiveUpdate: true })).toBe(old)
+    expect(() => migrateDataHome(home, { userHome })).toThrow('事务')
+    rmSync(join(old, 'updates/active.lock')); expect(migrateDataHome(home, { userHome })).toBe(true)
+    const marker = join(home, '.data-home-migration.json'); writeFileSync(marker, JSON.stringify({ source: old, target: home, phase: 'moving' }))
+    migrateDataHome(home, { userHome }); expect(JSON.parse(readFileSync(marker, 'utf8')).phase).toBe('complete')
+  })
+})

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

@@ -25,7 +25,7 @@ const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') =>
 it.skipIf(process.platform !== 'darwin')('persists official source migration while preserving custom sources and other service settings', () => {
   const home = temporary(), plistPath = join(home, 'fixture.plist')
   const driver = new LaunchAgentService({ home, port: 18899, releaseRoot: join(home, 'releases'), label: 'fixture', plistPath })
-  for (const [source, expected] of [['https://git.samien.cn/samien/hermes-yaoyao.git', 'https://github.com/Lsamien/hermes-yaoyao.git'], ['https://private.example/fork.git', 'https://private.example/fork.git']]) {
+  for (const [source, expected] of [['https://git.samien.cn/samien/hermes-yaoyao.git', 'https://github.com/Lsamien/yaoyao.git'], ['https://private.example/fork.git', 'https://private.example/fork.git']]) {
     driver.writePlist({ Label: 'fixture', EnvironmentVariables: { HERMES_YAOYAO_RELEASE_SOURCE: source, HERMES_YAOYAO_TLS_CERT: '/fixture/cert', CUSTOM_SETTING: 'preserve' } })
     const restored = JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8' }))
     expect(restored.EnvironmentVariables).toEqual({ HERMES_YAOYAO_RELEASE_SOURCE: expected, HERMES_YAOYAO_TLS_CERT: '/fixture/cert', CUSTOM_SETTING: 'preserve' })

+ 8 - 8
tests/server/githubReleases.test.ts

@@ -10,16 +10,16 @@ const commit = 'a'.repeat(40), tagObject = 'b'.repeat(40)
 const gitCalls = vi.hoisted(() => ({ execFile: vi.fn() }))
 vi.mock('node:child_process', async original => ({ ...await original<typeof import('node:child_process')>(), execFile: gitCalls.execFile }))
 const manifest = { schemaVersion: 1, releaseVersion: '0.4.2', webVersion: '0.4.2', gitTag: 'v0.4.2' }
-const api = 'https://api.github.com/repos/Lsamien/hermes-yaoyao'
+const api = 'https://api.github.com/repos/Lsamien/yaoyao'
 function fixture(overrides: Record<string, unknown> = {}) {
   const documents: Record<string, unknown> = {
     [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', body: 'Release notes', assets: [
-      { name: 'Yaoyao-0.4.2-arm64.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/Yaoyao-0.4.2-arm64.dmg' },
+      { name: 'Yaoyao-0.4.2-arm64.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://github.com/Lsamien/yaoyao/releases/download/v0.4.2/Yaoyao-0.4.2-arm64.dmg' },
       { name: 'bad.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://evil.test/bad.dmg' },
     ] },
     [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tag', sha: tagObject } },
     [`${api}/git/tags/${tagObject}`]: { object: { type: 'commit', sha: commit } },
-    [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: manifest,
+    [`https://raw.githubusercontent.com/Lsamien/yaoyao/${commit}/release.json`]: manifest,
     ...overrides,
   }
   return vi.fn(async (url: string | URL | Request) => {
@@ -30,10 +30,10 @@ function fixture(overrides: Record<string, unknown> = {}) {
 afterEach(() => { vi.unstubAllGlobals(); gitCalls.execFile.mockReset() })
 describe('GitHub release source', () => {
   it('migrates only known official sources and leaves user forks unchanged', () => {
-    for (const source of [undefined, '', 'https://git.samien.cn/samien/hermes-yaoyao.git', 'http://192.168.153.8:3000/samien/hermes-yaoyao/', 'git@git.samien.cn:samien/hermes-yaoyao.git']) expect(normalizeReleaseSource(source)).toBe(DEFAULT_RELEASE_SOURCE)
+    for (const source of [undefined, '', 'https://github.com/Lsamien/hermes-yaoyao', 'https://github.com/Lsamien/hermes-yaoyao.git/', 'git@github.com:Lsamien/hermes-yaoyao.git', 'https://git.samien.cn/samien/hermes-yaoyao.git', 'http://192.168.153.8:3000/samien/hermes-yaoyao/', 'git@git.samien.cn:samien/hermes-yaoyao.git']) expect(normalizeReleaseSource(source)).toBe(DEFAULT_RELEASE_SOURCE)
     for (const source of ['https://git.samien.cn/other/fork.git', 'https://private.example/repo.git', 'git@private.example:fork.git', 'https://github.com/other/fork.git']) expect(normalizeReleaseSource(source)).toBe(source)
     expect(normalizeReleaseSource('https://github.com/lsamien/Hermes-Yaoyao')).toBe(DEFAULT_RELEASE_SOURCE)
-    expect(githubRepository('https://github.com/Lsamien/hermes-yaoyao/')).toBe('Lsamien/hermes-yaoyao')
+    expect(githubRepository('https://github.com/Lsamien/yaoyao/')).toBe('Lsamien/yaoyao')
     expect(loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' }).releaseSource).toBe(DEFAULT_RELEASE_SOURCE)
     const plist = launchAgentPlist({ environment: { HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' } })
     expect(plist).toContain(DEFAULT_RELEASE_SOURCE)
@@ -42,9 +42,9 @@ describe('GitHub release source', () => {
   it('selects a published stable release, peels annotated tags and reads an immutable manifest', async () => {
     const fetchImpl = fixture()
     const result = await inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl)
-    expect(result).toMatchObject({ manifest, commit, notes: 'Release notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2' })
+    expect(result).toMatchObject({ manifest, commit, notes: 'Release notes', releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases/tag/v0.4.2' })
     expect(result.assets).toHaveLength(1)
-    expect(fetchImpl).toHaveBeenCalledWith(`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`, expect.anything())
+    expect(fetchImpl).toHaveBeenCalledWith(`https://raw.githubusercontent.com/Lsamien/yaoyao/${commit}/release.json`, expect.anything())
   })
   it('uses the GitHub adapter instead of cloning or contacting 9119', async () => {
     const fetchImpl = fixture(); vi.stubGlobal('fetch', fetchImpl)
@@ -67,7 +67,7 @@ describe('GitHub release source', () => {
     await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', ...patch } }))).rejects.toThrow('稳定')
   })
   it('rejects mismatching manifests and invalid tags', async () => {
-    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: { ...manifest, gitTag: 'v0.4.1' } }))).rejects.toThrow()
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`https://raw.githubusercontent.com/Lsamien/yaoyao/${commit}/release.json`]: { ...manifest, gitTag: 'v0.4.1' } }))).rejects.toThrow()
     await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tree', sha: commit } } }))).rejects.toThrow('标签提交')
   })
   it('reports network failure and cancellation explicitly', async () => {

+ 1 - 1
tests/server/security.test.ts

@@ -134,7 +134,7 @@ describe('server security boundary', () => {
     expect(value.host).toBe('127.0.0.1')
     expect(value.insecureLan).toBe(false)
     expect(value).not.toHaveProperty('yaoyaoPluginSource')
-    expect(value.releaseSource).toBe('https://github.com/Lsamien/hermes-yaoyao.git')
+    expect(value.releaseSource).toBe('https://github.com/Lsamien/yaoyao.git')
     expect(value.allowRemoteUpdate).toBe(false)
   })
 

+ 2 - 2
tests/server/updateManager.test.ts

@@ -107,8 +107,8 @@ describe('SystemUpdateManager', () => {
         sourceSeen = source; return { manifest: current, commit: 'd'.repeat(40) }
       },
     })
-    expect(await manager.check()).toMatchObject({ updateAvailable: false, releaseSource: 'https://github.com/Lsamien/hermes-yaoyao.git' })
-    expect(sourceSeen).toBe('https://github.com/Lsamien/hermes-yaoyao.git')
+    expect(await manager.check()).toMatchObject({ updateAvailable: false, releaseSource: 'https://github.com/Lsamien/yaoyao.git' })
+    expect(sourceSeen).toBe('https://github.com/Lsamien/yaoyao.git')
     await expect(manager.startUpdate(current.releaseVersion)).rejects.toThrow('最新版本')
   })
   it('reports the current Web release and discovers a newer fixed release', async () => {