Procházet zdrojové kódy

发布 v0.4.22:统一设置与工具桥并修复检查点恢复竞态

Samien před 1 měsícem
rodič
revize
d24dd67916

+ 6 - 8
README.md

@@ -1,8 +1,6 @@
 # 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.20**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.20.md)。
-
-当前源码与本地桌面构建为 **v0.4.21**,修正取消实验功能后的后台版本衔接,详见 [本地构建说明](docs/releases/v0.4.21.md)。该版本尚未发布到 GitHub 或 Docker Hub,下方下载和镜像链接仍指向已发布版本。
+当前 Web 与 macOS 桌面发布版本:**v0.4.22**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.22.md)。
 
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
@@ -40,7 +38,7 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 ## macOS 桌面版
 
-在 [v0.4.20 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.20) 下载 `Yaoyao-0.4.20-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
+在 [v0.4.22 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.22) 下载 `Yaoyao-0.4.22-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
 
 此 DMG 使用 Apple Development 开发签名,未做 Apple 公证。
 
@@ -50,11 +48,11 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 | 镜像 | 架构 |
 | --- | --- |
-| `samienluo/yaoyao:v0.4.20-amd` | AMD64 |
-| `samienluo/yaoyao:v0.4.20` | 自动匹配 AMD64 / ARM64 |
-| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.20` 相同 |
+| `samienluo/yaoyao:v0.4.22-amd` | AMD64 |
+| `samienluo/yaoyao:v0.4.22` | 自动匹配 AMD64 / ARM64 |
+| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.22` 相同 |
 
-准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.20`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
+准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.22`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
 
 ```sh
 docker compose --env-file docker.env pull web

+ 1 - 1
docker.env.example

@@ -3,7 +3,7 @@ HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 远程通用镜像支持 AMD64 / ARM64;每次发布更新 latest 并保留旧版本标签。
 # HERMES_YAOYAO_IMAGE=samienluo/yaoyao:latest
-# 固定版本:samienluo/yaoyao:v0.4.20;仅 AMD64:samienluo/yaoyao:v0.4.20-amd。
+# 固定版本:samienluo/yaoyao:v0.4.22;仅 AMD64:samienluo/yaoyao:v0.4.22-amd。
 # 留空时使用本地构建名称 yaoyao:local。
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # Cursor Universal 兼容桌面的本地构建名称;基础镜像在 deploy/computer/Dockerfile.cursor 中固定。

+ 7 - 7
docs/docker-install.md

@@ -12,14 +12,14 @@
 
 | 标签 | 架构 |
 | --- | --- |
-| `v0.4.20-amd` | `linux/amd64` |
-| `v0.4.20` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
-| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.20` 相同的镜像 |
+| `v0.4.22-amd` | `linux/amd64` |
+| `v0.4.22` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
+| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.22` 相同的镜像 |
 
 ```sh
-docker pull samienluo/yaoyao:v0.4.20
+docker pull samienluo/yaoyao:v0.4.22
 # 需要固定 AMD64 时:
-docker pull samienluo/yaoyao:v0.4.20-amd
+docker pull samienluo/yaoyao:v0.4.22-amd
 ```
 
 每次新版本发布都会更新 `latest`,旧版本标签继续保留。需要固定部署版本时使用具体版本标签。
@@ -34,7 +34,7 @@ cp docker.env.example docker.env
 
 | 配置 | 用途 |
 | --- | --- |
-| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.20` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
+| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.22` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址,默认 `http://host.docker.internal:9119` |
 | `HERMES_YAOYAO_BIND_ADDRESS` | Web 的宿主机发布地址,默认 `127.0.0.1`;局域网访问可设为 `0.0.0.0` |
 | `HERMES_YAOYAO_PUBLISHED_PORT` | 宿主机 Web 端口,默认 `15300` |
@@ -191,7 +191,7 @@ curl --fail http://127.0.0.1:15300/healthz
 使用 Docker Hub 发布镜像时,在 `docker.env` 设置:
 
 ```dotenv
-HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.20
+HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.22
 YAOYAO_CURSOR_DESKTOP_IMAGE=samienluo/yaoyao-desktop:v0.4.12-cursor-amd
 ```
 

+ 44 - 0
docs/releases/v0.4.22.md

@@ -0,0 +1,44 @@
+# 夭夭 AI v0.4.22
+
+本次发布 Web、macOS Apple Silicon 桌面版及 Docker Web 双架构镜像,不提供 APK。v0.4.21 为此前的本地构建,正式版本提升到 v0.4.22,便于已有安装正常升级。
+
+## 更新内容
+
+- 独立 Bot 设置,集中管理项目、用户记忆和本地虚拟机;“我的设置”保留账号资料、安全、外观、连接及系统管理。统一弹窗、分类导航、主题预览和窄屏布局。
+- 统一虚拟机 Bot 的基础会话、模型、视觉路由、技能与授权管理,保留虚拟机模式和本机协作模式各自的执行边界。
+- 新增工具桥插件的检查、安装、启用、更新与重新安装入口。区分已安装版本和实际加载版本,显示未启用、需更新、待加载和已就绪等状态,并保留安装备份与失败恢复。
+- 桌面安装包和执行节点安装包补齐工具桥、安装器及技能相关资源。Docker 镜像提供安装器依赖,并支持可选目录映射配置。
+- Bot 可在回合结束后独立复盘并沉淀可复用技能,继续保留记忆隔离;关闭基础会话的原始记忆和上下文文件自动加载,避免自动混入 Bot 上下文。
+- 修复会话创建的记忆参数兼容问题,以及刷新期间用量更新与检查点保存交错导致运行状态、待审批提示未恢复的问题。
+
+## 升级说明
+
+- 建议同步更新配套执行节点,并在“我的设置 → 连接”中检查工具桥。插件写入完成后,根据界面提示使目标服务加载新版本;“已安装”不等于“已加载”。
+- Docker 可按部署文档使用 `compose.hermes-bridge.yaml` 映射目标配置目录,目录需允许容器用户写入。仅在已配置的映射范围内执行安装。
+- 升级前备份整个 `.yaoyao` 数据目录,包括数据库、附件、项目与文件记忆。普通聊天继续使用 v2 协议,客户端需保持配套版本。
+- 本次不构建、不上传 APK,已有 Android 发布附件保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.22-arm64.dmg`:macOS Apple Silicon,Apple Development 开发签名,未公证。
+- `Yaoyao-0.4.22-source.tar.gz`:Web/桌面源码及部署配置。
+- `SHA256SUMS.txt`:以上两个附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.22-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.22` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后指向同一通用镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.22
+docker compose --env-file docker.env pull web
+docker compose --env-file docker.env up -d --no-build web
+```
+
+Compose 中设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.22`。旧版本标签和附件保留,镜像发布到 Docker Hub,GitHub 不上传镜像归档。
+
+附件摘要、镜像摘要和验证记录见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.22)。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "yaoyao",
-  "version": "0.4.21",
+  "version": "0.4.22",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "yaoyao",
-      "version": "0.4.21",
+      "version": "0.4.22",
       "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yaoyao",
-  "version": "0.4.21",
+  "version": "0.4.22",
   "private": true,
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",

+ 9 - 6
playwright.config.ts

@@ -1,15 +1,18 @@
 import { defineConfig, devices } from '@playwright/test'
 
+const webPort = Number(process.env.YAOYAO_E2E_PORT || 18801)
+const upstreamPort = Number(process.env.FAKE_HERMES_PORT || 19119)
+
 export default defineConfig({
   testDir: './tests/e2e',
   // These suites own different fixtures and run with their dedicated configurations.
-  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/native-environment.spec.ts'],
+  testIgnore: ['**/workspace-chat.spec.ts', '**/team-tools.spec.ts', '**/avatarReference.spec.ts', '**/avatarRoundTrip.spec.ts', '**/serverIdentity.spec.ts', '**/bot-panels.spec.ts', '**/grok-auth.spec.ts', '**/native-environment.spec.ts', '**/hermes-bridge.spec.ts'],
   timeout: 30_000,
   fullyParallel: false,
   workers: 1,
   reporter: 'list',
   use: {
-    baseURL: 'http://127.0.0.1:18801',
+    baseURL: `http://127.0.0.1:${webPort}`,
     viewport: { width: 1280, height: 720 },
     trace: 'retain-on-failure',
     screenshot: 'only-on-failure',
@@ -18,14 +21,14 @@ export default defineConfig({
   webServer: [
     {
       command: 'node tests/fixtures/fake-hermes.mjs',
-      port: 19119,
-      reuseExistingServer: true,
+      port: upstreamPort,
+      reuseExistingServer: !process.env.YAOYAO_E2E_PORT,
       timeout: 15_000,
     },
     {
       command: 'node tests/fixtures/e2e-web-server.mjs',
-      url: 'http://127.0.0.1:18801/healthz',
-      reuseExistingServer: true,
+      url: `http://127.0.0.1:${webPort}/healthz`,
+      reuseExistingServer: !process.env.YAOYAO_E2E_PORT,
       timeout: 15_000,
     },
   ],

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.21",
-  "webVersion": "0.4.21",
-  "gitTag": "v0.4.21"
+  "releaseVersion": "0.4.22",
+  "webVersion": "0.4.22",
+  "gitTag": "v0.4.22"
 }

+ 7 - 4
src/client/stores/chat.ts

@@ -148,11 +148,14 @@ export const useChatStore = defineStore('chat', () => {
         const messages=[...canonical,...pending]
         const {messages:_,...checkpoint}=value
         await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint},true)
-        const latest=current();if(!latest)return
-        const newerPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
-        latest.messages=[...canonical,...newerPending];latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
+        const beforeOutbox=current();if(!beforeOutbox)return
+        const newerPending=beforeOutbox.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         await outboxCache.set(scope,id,newerPending,true)
-        if(current()!==latest)return
+        // Usage events replace the route while storage is pending. Commit to
+        // the current route after both writes, preserving any newer local send.
+        const latest=current();if(!latest)return
+        const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
+        latest.messages=[...canonical,...latestPending];latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
         const approval=value.pendingApproval,clarification=value.pendingClarification
         latest.pendingApproval=approval?{id:string(approval.request_id??approval.id),sessionId:id,message:string(approval.message??approval.prompt),toolName:string(approval.tool_name),choices:values(approval.choices).map(String),payload:approval as Record<string,JsonValue>}:undefined
         latest.pendingClarification=clarification?{id:string(clarification.request_id??clarification.id),sessionId:id,question:string(clarification.question??clarification.prompt),payload:clarification as Record<string,JsonValue>}:undefined

+ 32 - 0
tests/client/chatCrossClientSync.test.ts

@@ -68,6 +68,7 @@ vi.mock('@/stores/auth', () => ({
   }),
 }))
 import { useChatStore } from '@/stores/chat'
+import { ScopedCache } from '@/utils/cache'
 function session(id = 's', owned = true) {
   return {
     id,
@@ -149,6 +150,37 @@ describe('ordinary v2 cross-client store', () => {
     expect(chat.isStreaming).toBe(false)
     expect(api.messages).not.toHaveBeenCalled()
   })
+  it('keeps checkpoint state when a usage update replaces the route during outbox persistence', async () => {
+    const client = await open()
+    const original = ScopedCache.prototype.set
+    let entered!: () => void, release!: () => void
+    const started = new Promise<void>(resolve => { entered = resolve })
+    const gate = new Promise<void>(resolve => { release = resolve })
+    const spy = vi.spyOn(ScopedCache.prototype, 'set').mockImplementation(async function(this: ScopedCache<unknown>, scope, key, value, durable) {
+      if ((this as unknown as { namespace: string }).namespace === 'ordinary-outbox-v2') {
+        entered()
+        await gate
+      }
+      await original.call(this, scope, key, value, durable)
+    })
+    const saving = client.changed(snapshot([answer('正在处理', { status: 'streaming' })], 's', {
+      running: true, liveStatus: '正在思考', pendingApproval: { request_id: 'approval-1', message: '请确认' },
+    }))
+    try {
+      await started
+      wire.event?.({ type: 'session.usage', session_id: 's', profile: 'p', payload: { input_tokens: 7 } })
+      release()
+      await saving
+      expect(chat.messages.map(m => m.content)).toEqual(['正在处理'])
+      expect(chat.isStreaming).toBe(true)
+      expect(chat.pendingApproval?.id).toBe('approval-1')
+      expect(chat.contextUsage?.inputTokens).toBe(7)
+    } finally {
+      release()
+      await saving.catch(() => {})
+      spy.mockRestore()
+    }
+  })
   it('does not materialize a resume snapshot beside the canonical reply', async () => {
     wire.request.mockResolvedValue({
       session_id: 'runtime',

+ 1 - 1
tests/e2e/hermes-bridge.spec.ts

@@ -22,7 +22,7 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
   await page.getByRole('button',{name:'登录',exact:true}).click()
   await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
-  await page.getByRole('menuitem',{name:'设置',exact:true}).click()
+  await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
   await page.getByRole('button',{name:'Hermes 连接',exact:true}).click()
   const panel=page.getByRole('region',{name:'工具桥插件',exact:true})
   await expect(panel.getByText('未安装',{exact:true})).toBeVisible()

+ 3 - 3
tests/e2e/offlineUpdate.spec.ts

@@ -52,9 +52,9 @@ test('opens settings and schedules an independent Web update while all 9119 requ
     expect(login.ok(), `${login.status()} ${signedIn.error || ''}`).toBe(true)
     expect((await page.request.get(origin + '/readyz')).status()).toBe(503)
     await page.goto(origin + '/chat', { waitUntil: 'domcontentloaded' })
-    await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).click()
-    await page.getByRole('menuitem', { name: '设置', exact: true }).click()
-    const dialog = page.getByRole('dialog', { name: '设置中心' })
+    await page.getByRole('button', { name: /^(打开我的设置|设置与模式)$/ }).click()
+    await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '我的设置' })
     await dialog.getByRole('button', { name: '系统概览', exact: true }).click()
     await expect(dialog.getByText(`Web ${current.webVersion} · 可独立升级与回滚`)).toBeVisible()
     await dialog.getByRole('button', { name: '更新与回滚', exact: true }).click()

+ 16 - 12
tests/e2e/team-tools.spec.ts

@@ -204,8 +204,8 @@ test('registers an execution node, downloads its private config and disables it'
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
   await page.getByRole('button',{name:'登录',exact:true}).click()
   await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
-  await page.getByRole('menuitem',{name:'设置',exact:true}).click()
-  const dialog=page.getByRole('dialog',{name:'设置中心'})
+  await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
+  const dialog=page.getByRole('dialog',{name:'我的设置'})
   await dialog.getByRole('button',{name:'Hermes 连接',exact:true}).click()
   await dialog.locator('details.runner-settings summary').click()
   const panel=dialog.getByRole('region',{name:'执行节点设置'})
@@ -256,6 +256,14 @@ test('configures the Agent computer in its chat panel and keeps preparation in a
  await page.screenshot({path:testInfo.outputPath('local-vm-unconfigured-agent.png')})
 })
 
+async function openVmSettings(page:any){
+ await page.locator('.desktop-sidebar').getByRole('button',{name:'工具',exact:true}).click()
+ await page.getByRole('menuitem',{name:'Bot 设置',exact:true}).click()
+ const settings=page.getByRole('dialog',{name:'Bot 设置',exact:true})
+ await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ return settings
+}
+
 async function login(page:any,ready=false){
  await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
@@ -263,14 +271,13 @@ async function login(page:any,ready=false){
 }
 test('persists idle-stop settings and the host environment checkbox on desktop and mobile',async({page},testInfo)=>{
  await login(page,true)
- await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click();await page.getByRole('menuitem',{name:'设置',exact:true}).click()
- const settings=page.getByRole('dialog',{name:'设置中心'});await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ const settings=await openVmSettings(page)
  const timeout=settings.getByRole('combobox',{name:'虚拟机空闲停止时间',exact:true})
  await timeout.selectOption('30');await expect(timeout).toHaveValue('30')
  await timeout.selectOption('0');await expect(settings).toContainText('空闲时保持运行')
  expect((await(await page.request.get('/api/app/admin/local-vm')).json()).idleStopMinutes).toBe(0)
  await timeout.scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('idle-stop-settings.png')})
- await settings.getByRole('button',{name:'关闭设置中心',exact:true}).click()
+ await settings.getByRole('button',{name:'关闭Bot 设置',exact:true}).click()
  await page.request.post('/__test/hybrid-runner',{data:{}})
  const seed=await(await page.request.post('/__test/computer?off=1',{data:{}})).json()
  await page.goto(`/conversations/${seed.conversationId}`);await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
@@ -323,10 +330,7 @@ test('cloud VM keeps its own selection while the host option can be checked and
 test('Local VM settings replace the image catalog and prepare the managed desktop',async({page},testInfo)=>{
  await login(page)
  await expect(page.getByRole('button',{name:'电脑与环境',exact:true})).toHaveCount(0)
- await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
- await page.getByRole('menuitem',{name:'设置',exact:true}).click()
- const settings=page.getByRole('dialog',{name:'设置中心'})
- await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+ const settings=await openVmSettings(page)
  const panel=settings.getByRole('region',{name:'本地虚拟机设置'})
  await expect(panel).toContainText('隔离方式')
  await expect(panel.getByRole('button',{name:'上传并导入'})).toHaveCount(0)
@@ -334,7 +338,7 @@ test('Local VM settings replace the image catalog and prepare the managed deskto
  await expect(panel).toContainText('虚拟机环境已就绪')
  await panel.getByRole('combobox',{name:'虚拟机数量上限'}).selectOption('3')
  await expect(panel.getByRole('combobox',{name:'虚拟机数量上限'})).toHaveValue('3')
- await panel.getByRole('heading',{name:'本地虚拟机',exact:true}).scrollIntoViewIfNeeded()
+ await panel.scrollIntoViewIfNeeded()
  await page.screenshot({path:testInfo.outputPath('local-vm-settings.png')})
  expect((await page.request.get('/api/app/admin/runners/11111111-1111-4111-8111-111111111111/images')).status()).toBe(404)
 })
@@ -347,9 +351,9 @@ test('Agent computer appears as a right panel and opens an interactive desktop',
  const dock=page.getByRole('complementary',{name:'机器人电脑面板'})
  await expect(dock).toBeVisible();await expect(dock).toContainText('本地虚拟机')
  await dock.getByRole('button',{name:'设置本地虚拟机',exact:true}).click()
- const settings=page.getByRole('dialog',{name:'设置中心'})
+ const settings=page.getByRole('dialog',{name:'Bot 设置'})
  await settings.getByRole('button',{name:'准备所选镜像',exact:true}).click()
- await settings.getByRole('button',{name:'关闭设置中心',exact:true}).click()
+ await settings.getByRole('button',{name:'关闭Bot 设置',exact:true}).click()
  await expect(dock.getByRole('button',{name:/创建.*的虚拟机/})).toBeVisible({timeout:12000})
  await dock.getByRole('button',{name:/创建.*的虚拟机/}).click()
  await expect(dock.getByRole('img')).toBeVisible()

+ 19 - 18
tests/e2e/workspace-chat.spec.ts

@@ -223,12 +223,12 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.locator('.sidebar-account-switcher__main')).toContainText('fixture')
   await expect(rail.locator('.sidebar-account-switcher__main .account-initial-avatar')).toHaveText('F')
   await rail.locator('.sidebar-account-switcher__main').click()
-  await expect(page.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['设置', '关于', '检测更新', '帮助', '进入聊天模式'])
+  await expect(page.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['我的设置', '关于', '检测更新', '帮助', '进入聊天模式'])
   await page.getByRole('menuitem', { name: '关于', exact: true }).click()
   const about = page.getByRole('dialog', { name: '关于夭夭 AI', exact: true })
   await expect(about).toBeVisible()
   await expect(about.locator('.about-version')).toHaveText(/^版本 \d+\.\d+\.\d+$/)
-  await expect(page.getByRole('dialog', { name: '设置中心', exact: true })).toHaveCount(0)
+  await expect(page.getByRole('dialog', { name: '我的设置', exact: true })).toHaveCount(0)
   await page.screenshot({ path: testInfo.outputPath('independent-about.png') })
   await page.keyboard.press('Escape')
   await expect(about).toHaveCount(0)
@@ -250,29 +250,30 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.getByRole('button', { name: '返回聊天', exact: true }).click()
   await expect(page).toHaveURL(/\/conversations$/)
   await rail.locator('.sidebar-account-switcher__main').click()
-  await page.getByRole('menuitem', { name: '设置', exact: true }).click()
-  const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
-  await expect(settings.locator('.settings-account-summary')).toContainText('fixture')
-  await expect(settings.locator('.settings-account-summary .account-initial-avatar')).toHaveText('F')
+  await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+  const settings = page.getByRole('dialog', { name: '我的设置', exact: true })
+  await settings.getByRole('button', {name:'账号资料',exact:true}).click()
+  await expect(settings.locator('.account-avatar-card')).toContainText('fixture')
+  await expect(settings.locator('.account-avatar-card .account-initial-avatar')).toHaveText('F')
   await settings.locator('.account-avatar-card input[type=file]').setInputFiles({name:'account.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')})
-  await expect(settings.locator('.settings-account-summary .account-initial-avatar img')).toBeVisible()
+  await expect(settings.locator('.account-avatar-card .account-initial-avatar img')).toBeVisible()
   expect((await (await page.request.get('/api/app/bootstrap')).json()).user.avatar).toMatch(/^data:image\/png;base64,/)
   await settings.getByRole('button', {name:'使用首字母',exact:true}).click()
-  await expect(settings.locator('.settings-account-summary .account-initial-avatar')).toHaveText('F')
+  await expect(settings.locator('.account-avatar-card .account-initial-avatar')).toHaveText('F')
   await settings.getByRole('button', { name: '身份与头像', exact: true }).click()
   await settings.getByRole('button', { name: '造型:星星', exact: true }).click()
   await settings.getByRole('button', { name: '使用颜色 #1488ff', exact: true }).click()
   await settings.locator('.mascot-grid--expression button').filter({ hasText: '开心' }).click()
   await settings.getByRole('button', { name: '保存更改', exact: true }).click()
   await expect(settings.getByRole('button', { name: '保存更改', exact: true })).toBeDisabled()
-  await settings.getByRole('button', { name: '关闭设置中心', exact: true }).click()
+  await settings.getByRole('button', { name: '关闭我的设置', exact: true }).click()
   await rail.locator('.sidebar-account-switcher__main').click()
-  await page.getByRole('menuitem', {name:'设置',exact:true}).click()
+  await page.getByRole('menuitem', {name:'我的设置',exact:true}).click()
   await settings.getByRole('button', { name: '身份与头像', exact: true }).click()
   await expect(settings.getByRole('button', { name: '造型:星星', exact: true })).toHaveAttribute('aria-pressed', 'true')
   await expect(settings.getByRole('button', { name: '使用颜色 #1488ff', exact: true })).toHaveAttribute('aria-pressed', 'true')
   await expect(settings.locator('.mascot-grid--expression button').filter({ hasText: '开心' })).toHaveAttribute('aria-pressed', 'true')
-  await settings.getByRole('button', { name: '关闭设置中心', exact: true }).click()
+  await settings.getByRole('button', { name: '关闭我的设置', exact: true }).click()
   await expect(rail.getByRole('checkbox', { name: '显示已归档' })).toHaveCount(0)
   const plus = rail.getByRole('button', { name: '新建', exact: true })
   await expect(rail.locator('.sidebar-collapse')).toHaveCount(0)
@@ -287,11 +288,11 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.getByRole('button', { name: '聊天', exact: true })).toHaveCount(0)
   await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
   const settingsMenu = page.getByRole('menu', { name: '账号菜单', exact: true })
-  await expect(settingsMenu.getByRole('menuitem')).toHaveText(['设置', '关于', '检测更新', '帮助', '进入 Bot 模式'])
+  await expect(settingsMenu.getByRole('menuitem')).toHaveText(['我的设置', '关于', '检测更新', '帮助', '进入 Bot 模式'])
   await page.screenshot({ path: testInfo.outputPath('chat-settings-mode-menu.png') })
-  await settingsMenu.getByRole('menuitem', { name: '设置', exact: true }).click()
-  await expect(page.getByRole('dialog', { name: '设置中心', exact: true })).toBeVisible()
-  await page.getByRole('button', { name: '关闭设置中心', exact: true }).click()
+  await settingsMenu.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+  await expect(page.getByRole('dialog', { name: '我的设置', exact: true })).toBeVisible()
+  await page.getByRole('button', { name: '关闭我的设置', exact: true }).click()
   await expect(rail.getByRole('button', { name: '设置与模式', exact: true })).toBeFocused()
   await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
   await settingsMenu.getByRole('menuitem', { name: '进入 Bot 模式', exact: true }).click()
@@ -553,8 +554,8 @@ test('created roles and editable teams share a durable chat list without plugin
 test('subaccount allocation and Bot-only navigation work through the browser', async ({ page, browser }, testInfo) => {
   await login(page)
   await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
-  await page.getByRole('menuitem', { name: '设置', exact: true }).click()
-  const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
+  await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+  const settings = page.getByRole('dialog', { name: '我的设置', exact: true })
   await settings.getByRole('button', { name: '用户与权限', exact: true }).click()
   const username = `child-${Date.now()}`
   await settings.locator('input[name=managed-username]').fill(username)
@@ -577,7 +578,7 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   await expect(child).toHaveURL(/\/conversations$/)
   await expect(child.getByRole('heading', { name: '还没有聊天' })).toBeVisible()
   await child.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
-  await expect(child.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['设置', '关于', '帮助'])
+  await expect(child.getByRole('menu', { name: '账号菜单' }).getByRole('menuitem')).toHaveText(['我的设置', '关于', '帮助'])
   await child.keyboard.press('Escape')
   await openCreate(child, '新建 Bot')
   const dialog = child.getByRole('dialog')

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

@@ -16,7 +16,7 @@ test.beforeAll(async ({ request }) => {
   const initial = await request.get('/api/app/bootstrap')
   const initialBody = await initial.json() as { csrfToken: string; setupRequired?: boolean }
   const login = await request.post(initialBody.setupRequired ? '/api/app/setup' : '/api/app/login', {
-    headers: { Origin: 'http://127.0.0.1:18801', 'X-CSRF-Token': initialBody.csrfToken },
+    headers: { Origin: `http://127.0.0.1:${process.env.YAOYAO_E2E_PORT || '18801'}`, 'X-CSRF-Token': initialBody.csrfToken },
     data: { username: 'admin', password: E2E_PASSWORD },
   })
   expect(login.ok()).toBe(true)
@@ -50,9 +50,9 @@ test('separates writable Web chats from read-only Hermes history', async ({ page
 })
 
 test('explains optional upstream credentials without blocking a ready connection', async ({ page }, testInfo) => {
-  await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).first().click()
-  await page.getByRole('menuitem', { name: '设置', exact: true }).click()
-  const dialog = page.getByRole('dialog', { name: '设置中心' })
+  await page.getByRole('button', { name: /^(打开我的设置|设置与模式)$/ }).first().click()
+  await page.getByRole('menuitem', { name: '我的设置', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '我的设置' })
   await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
   await expect(dialog.locator('[aria-label="Hermes 连接状态"]')).toBeVisible()
   await expect(dialog.getByText('9119 连接正常', { exact: true })).toBeVisible()
@@ -310,20 +310,20 @@ test.skip('pins the named Agent typing status above the group composer', async (
 test.skip('reconnects the group event stream after an unexpected close', async ({ page }) => {
   await page.getByRole('button', { name: '团队' }).click()
   await expect(page.getByText('已同步', { exact: true })).toBeVisible()
-  const before = await (await page.request.get('http://127.0.0.1:19119/__test/group-connections')).json() as { count: number }
-  await page.request.post('http://127.0.0.1:19119/__test/groups/disconnect')
+  const before = await (await page.request.get(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/group-connections`)).json() as { count: number }
+  await page.request.post(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/groups/disconnect`)
   await expect.poll(async () => {
-    const value = await (await page.request.get('http://127.0.0.1:19119/__test/group-connections')).json() as { count: number }
+    const value = await (await page.request.get(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/group-connections`)).json() as { count: number }
     return value.count
   }, { timeout: 5_000 }).toBeGreaterThan(before.count)
   await expect(page.getByText('已同步', { exact: true })).toBeVisible()
 })
 
 test.skip('recovers the group page after its initial upstream connection fails', async ({ page }) => {
-  await page.request.post('http://127.0.0.1:19119/__test/groups/availability', { data: { available: false } })
+  await page.request.post(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/groups/availability`, { data: { available: false } })
   await page.getByRole('button', { name: '团队' }).click()
   await expect(page.getByRole('heading', { name: '团队服务暂不可用' })).toBeVisible()
-  await page.request.post('http://127.0.0.1:19119/__test/groups/availability', { data: { available: true } })
+  await page.request.post(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/groups/availability`, { data: { available: true } })
   await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible({ timeout: 5_000 })
 })
 
@@ -543,7 +543,7 @@ test('opens a conversation even when marking it read is unsupported', async ({ p
 })
 
 test('syncs the blue fast-mode shortcut with the iOS-compatible session config', async ({ page }) => {
-  await page.request.post('http://127.0.0.1:19119/__test/rpc-requests/reset')
+  await page.request.post(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/rpc-requests/reset`)
   await page.goto('/chat/session-demo')
   const fast = page.getByRole('button', { name: '快速模式:已关闭' })
   await expect(fast).toHaveAttribute('aria-pressed', 'false')
@@ -556,7 +556,7 @@ test('syncs the blue fast-mode shortcut with the iOS-compatible session config',
   await page.getByRole('button', { name: '发送消息' }).click()
   await expect(page.getByText('这是来自假 Gateway 的流式回复。', { exact: true })).toBeVisible()
 
-  const payload = await (await page.request.get('http://127.0.0.1:19119/__test/rpc-requests')).json() as { requests: Array<{ method: string; params: Record<string, unknown> }> }
+  const payload = await (await page.request.get(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/rpc-requests`)).json() as { requests: Array<{ method: string; params: Record<string, unknown> }> }
   const fastConfigIndex = payload.requests.findIndex(request => request.method === 'config.set' && request.params.key === 'fast')
   const promptIndex = payload.requests.findIndex(request => request.method === 'prompt.submit')
   expect(fastConfigIndex).toBeGreaterThanOrEqual(0)
@@ -566,20 +566,20 @@ test('syncs the blue fast-mode shortcut with the iOS-compatible session config',
   await enabled.click()
   await expect(page.getByRole('button', { name: '快速模式:已关闭' })).toHaveAttribute('aria-pressed', 'false')
   await expect.poll(async () => {
-    const next = await (await page.request.get('http://127.0.0.1:19119/__test/rpc-requests')).json() as typeof payload
+    const next = await (await page.request.get(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/rpc-requests`)).json() as typeof payload
     return next.requests.filter(request => request.method === 'config.set' && request.params.key === 'fast').at(-1)?.params.value
   }).toBe('normal')
 })
 
 test('reconciles an iOS fast-mode selection from the resumed 9119 session', async ({ page }) => {
-  await page.request.post('http://127.0.0.1:19119/__test/rpc-requests/reset')
+  await page.request.post(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/rpc-requests/reset`)
   await page.goto('/chat/session-yaoer?profile=yaoer')
   await expect(page.getByRole('button', { name: '快速模式:已关闭' })).toBeVisible()
   await page.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' }).fill('读取 iOS 快速模式')
   await page.getByRole('button', { name: '发送消息' }).click()
   await expect(page.getByRole('button', { name: '快速模式:已开启' })).toHaveCSS('color', 'rgb(22, 119, 255)')
 
-  const payload = await (await page.request.get('http://127.0.0.1:19119/__test/rpc-requests')).json() as { requests: Array<{ method: string; params: Record<string, unknown> }> }
+  const payload = await (await page.request.get(`http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}/__test/rpc-requests`)).json() as { requests: Array<{ method: string; params: Record<string, unknown> }> }
   expect(payload.requests.some(request => request.method === 'session.resume' && request.params.session_id === 'session-yaoer')).toBe(true)
   expect(payload.requests.some(request => request.method === 'config.set' && request.params.key === 'fast')).toBe(false)
 })

+ 5 - 5
tests/fixtures/e2e-web-server.mjs

@@ -1,9 +1,9 @@
 import { randomBytes, scryptSync } from 'node:crypto'
-import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 
-const testHome = '/tmp/hermes-yaoyao-e2e-home'
+const testHome = mkdtempSync('/tmp/hermes-yaoyao-e2e-home-')
 const testUserID = '11111111-1111-4111-8111-111111111112'
-rmSync(testHome, { recursive: true, force: true })
+process.once('exit', () => rmSync(testHome, { recursive: true, force: true }))
 mkdirSync(testHome, { recursive: true, mode: 0o700 })
 
 // The browser fixture exercises the ownership registry, not the mutable
@@ -66,8 +66,8 @@ Object.assign(process.env, {
   NODE_ENV: 'production',
   HERMES_YAOYAO_HOME: testHome,
   HERMES_YAOYAO_HOST: '127.0.0.1',
-  HERMES_YAOYAO_PORT: '18801',
-  HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
+  HERMES_YAOYAO_PORT: process.env.YAOYAO_E2E_PORT || '18801',
+  HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${process.env.FAKE_HERMES_PORT || '19119'}`,
   HERMES_YAOYAO_UPSTREAM_USERNAME: 'test',
   HERMES_YAOYAO_UPSTREAM_PASSWORD: 'test',
 })

+ 1 - 1
tests/fixtures/workspace-server.ts

@@ -487,7 +487,7 @@ runtime.runners.localVm=async(owner,id,p)=>{
  return {...vmFixture}
 }
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;vmFixture.idleStopMinutes=5;vmFixture.job=undefined;(runtime.runners as any).online.delete(vmRunnerId);if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
-runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/hybrid-runner')return next();auth.requireAdmin(ctx);(runtime.runners as any).online.set(vmRunnerId,{instance:randomUUID(),epoch:'fixture',seen:Date.now(),features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1','host-computer-tools-v1','idle-stop-policy-v1','profile-computer-v1']});ctx.body={ok:true}})
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/hybrid-runner')return next();auth.requireAdmin(ctx);(runtime.runners as any).online.set(vmRunnerId,{instance:randomUUID(),epoch:'fixture',seen:Date.now(),features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1','host-computer-tools-v1','idle-stop-policy-v1','profile-computer-v1','hermes-computer-v2','workspace-memory-bind-v1']});ctx.body={ok:true}})
 runtime.app.use((ctx,next)=>{
  if(ctx.path!=='/__test/cloud-host-option')return next();const owner=auth.requireAdmin(ctx).id,cloud=runtime.workspaceRuntime.cloud!
  cloud.state=async()=>({configured:true,running:false});cloud.status=async()=>({backend:'grok',mode:'off'});cloud.requireAvailable=async()=>{};cloud.connect=async()=>({} as any)