Jelajahi Sumber

发布 v0.4.61:汇总桌面与 Bot 更新并校准回归测试

Samien 1 bulan lalu
induk
melakukan
254dde5793

+ 6 - 6
README.md

@@ -1,6 +1,6 @@
 # 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.60**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.22.md)。
+当前 Web 与 macOS 桌面发布版本:**v0.4.61**。桌面版提供 Apple Silicon(arm64)DMG,详见 [发布说明](docs/releases/v0.4.61.md)。
 
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
@@ -40,7 +40,7 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 ## macOS 桌面版
 
-在 [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)。
+在 [v0.4.61 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.61) 下载 `Yaoyao-0.4.61-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
 
 此 DMG 使用 Apple Development 开发签名,未做 Apple 公证。
 
@@ -50,11 +50,11 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 | 镜像 | 架构 |
 | --- | --- |
-| `samienluo/yaoyao:v0.4.22-amd` | AMD64 |
-| `samienluo/yaoyao:v0.4.22` | 自动匹配 AMD64 / ARM64 |
-| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.22` 相同 |
+| `samienluo/yaoyao:v0.4.61-amd` | AMD64 |
+| `samienluo/yaoyao:v0.4.61` | 自动匹配 AMD64 / ARM64 |
+| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.61` 相同 |
 
-准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.22`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
+准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.61`(或 `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.22;仅 AMD64:samienluo/yaoyao:v0.4.22-amd。
+# 固定版本:samienluo/yaoyao:v0.4.61;仅 AMD64:samienluo/yaoyao:v0.4.61-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.22-amd` | `linux/amd64` |
-| `v0.4.22` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
-| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.22` 相同的镜像 |
+| `v0.4.61-amd` | `linux/amd64` |
+| `v0.4.61` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
+| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.61` 相同的镜像 |
 
 ```sh
-docker pull samienluo/yaoyao:v0.4.22
+docker pull samienluo/yaoyao:v0.4.61
 # 需要固定 AMD64 时:
-docker pull samienluo/yaoyao:v0.4.22-amd
+docker pull samienluo/yaoyao:v0.4.61-amd
 ```
 
 每次新版本发布都会更新 `latest`,旧版本标签继续保留。需要固定部署版本时使用具体版本标签。
@@ -34,7 +34,7 @@ cp docker.env.example docker.env
 
 | 配置 | 用途 |
 | --- | --- |
-| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.22` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
+| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.61` 或 `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.22
+HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.61
 YAOYAO_CURSOR_DESKTOP_IMAGE=samienluo/yaoyao-desktop:v0.4.12-cursor-amd
 ```
 

+ 45 - 0
docs/releases/v0.4.61.md

@@ -0,0 +1,45 @@
+# 夭夭 AI v0.4.61
+
+本次发布 Web、macOS Apple Silicon 桌面版和 Docker 双架构镜像,不提供 APK。此版本汇总 v0.4.22 之后的本地更新,并包含最新头像刷新修复。
+
+## 更新内容
+
+- 修复刷新页面时短暂出现绿色默认头像的问题。头像资料尚未加载或图片失效时显示中性占位,保留用户设置的照片与造型。
+- 完善 Bot 资料、结构化角色偏好、独立模型设置与分层记忆;修复冷恢复后模型设置失效、记忆提炼空结果和记忆检索问题,并显示保存结果。
+- 完善多台电脑及虚拟环境管理,支持桌面端切换客户端与服务器模式、远程登录、逐台授权、电脑改名和重新授权。
+- 电脑接管页面支持切换预览环境,减少轮询造成的闪跳。完善共享桌面的执行调度、多环境操作、文件与命令授权及跨设备文件复制。
+- 修复 Bot 会话同步、普通聊天排队与停止状态、节点失联时的任务退出,以及工具分段完成后输入动画提前消失或持续残留的问题。
+- 消息正文、图片和文件按原顺序独立展示;统一文件卡片识别及传输上限,提供聊天气泡预设与自定义外观。
+- 统一任务进度、审批、提问与错误提示,默认折叠交付进展,默认隐藏思考过程和工具细节,减少重复技术报错。
+- 修复文件路径权限边界,并在归档或删除 Bot 时清理相关电脑共享关系。
+
+## 升级说明
+
+- 升级前备份完整 `.yaoyao` 数据目录。保留旧数据目录迁移兼容逻辑,不改变现有数据归属。
+- 建议同步更新桌面端与配套执行节点,并检查工具桥实际加载状态及电脑授权状态。
+- 本次不构建、不上传 APK;旧版本标签和附件继续保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.61-arm64.dmg`:macOS Apple Silicon,Apple Development 开发签名,未公证。
+- `Yaoyao-0.4.61-source.tar.gz`:Web/桌面源码及部署配置。
+- `SHA256SUMS.txt`:上述两个附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.61-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.61` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后更新为同一通用镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.61
+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.61`。镜像发布到 Docker Hub,GitHub 仅提供安装包、源码及校验文件。
+
+附件摘要、镜像摘要与实际验证结果见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.61)。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.60",
-  "webVersion": "0.4.60",
-  "gitTag": "v0.4.60"
+  "releaseVersion": "0.4.61",
+  "webVersion": "0.4.61",
+  "gitTag": "v0.4.61"
 }

+ 3 - 0
tests/server/workerCompaction.test.ts

@@ -14,6 +14,8 @@ boot=json.loads(sys.stdin.readline())
 def emit(kind, **data):
     print(json.dumps({"nonce":boot["nonce"],"type":kind,**data}),flush=True)
 payload=json.loads(boot["prompt"])
+emit("tool",id="fixture-shell",name="computer_shell",arguments={"command":"true"})
+json.loads(sys.stdin.readline())
 emit("checkpoint",messages=payload.pop("checkpoint",boot["history"]))
 emit("probe",boot=boot)
 emit("complete",**payload)
@@ -25,6 +27,7 @@ for line in sys.stdin:
  const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner'},gateways:ComputerGateway[]=[]
  vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.',model:{provider:'custom',api_mode:'chat_completions',model:'fixture'},contextConfig:{compression:{enabled:true,threshold:0.5}}})
  vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:'fixture',running:true,workspace:home,isolation:'container'})
+ vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'',stderr:'',exitCode:0})
  const stop=vi.spyOn(runtime.provider,'stop').mockResolvedValue(),remove=vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  async function turn(payload:Record<string,unknown>,sessionId?:string){
   const gateway=new ComputerGateway(runtime,meta,randomUUID(),async()=>{},async()=>({}));gateways.push(gateway)

+ 3 - 1
tests/server/workspace.test.ts

@@ -349,6 +349,7 @@ describe('Web-owned workspace', () => {
     }
     const complete = (item: typeof pending[number]) => {
         const text = item.params.text.includes('第一项工作') ? '第一项结果' : '第二项结果'
+        item.socket.send(JSON.stringify({method:'event',params:{type:'context.update',session_id:item.params.session_id,payload:{percent:40}}}))
         setTimeout(() => item.socket.send(JSON.stringify({
           method: 'event',
           params: { type: 'message.complete', session_id: item.params.session_id, payload: { text, status: 'complete' } },
@@ -531,7 +532,8 @@ describe('Web-owned workspace', () => {
     const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '执行' })
     await finished(run.id)
     expect(store.messages(owner, c.id).at(-1)?.content).toBe('本轮结果')
-    expect(store.get<{ percent: number }>(owner, 'context', c.id)?.percent).toBe(40)
+    expect(requests.some(request => request.method === 'session.usage')).toBe(false)
+    expect(store.list<any>(owner, 'agent-token-day')).toEqual([expect.objectContaining({agentId:a.id,output:4,total:expect.any(Number)})])
   })
   it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
     modelBridge()

+ 13 - 31
tests/server/workspacePairedNodes.test.ts

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { WorkspaceNodes, WorkspaceGateway, type WorkspaceNode } from '../../src/server/workspaceGateway'
+import { WorkspaceNodes, type WorkspaceNode } from '../../src/server/workspaceGateway'
 import { parseWorkspacePairCode } from '../../src/server/workspacePairedNode'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { loadServerConfig } from '../../src/server/config'
@@ -47,7 +47,7 @@ describe('workspace 15300 child nodes', () => {
     expect(()=>parseWorkspacePairCode(code().replace('://pair','://login'))).toThrow()
     expect(()=>parseWorkspacePairCode(code()+'&node='+remote)).toThrow()
   })
-  it('pairs only under the owner, encrypts credentials, preserves agent binding when changing IP, and executes via HTTP/SSE', async () => {
+  it('pairs only under the owner, encrypts credentials, preserves agent binding when changing IP, and refuses execution of a retired paired node', async () => {
     const t=setup(); await t.nodes.pair('parent-user',{qrPayload:code(),name:'远端'})
     const node=t.store.list<WorkspaceNode>('parent-user','node')[0]!
     expect(node.transport).toBe('paired-web'); expect(node.url).toContain(':15300')
@@ -56,46 +56,28 @@ describe('workspace 15300 child nodes', () => {
     const agent=t.store.createAgent('parent-user',{name:'策划',profile:'remote-profile',nodeId:node.id,instructions:''})
     await t.nodes.update('parent-user',node.id,{name:'新名字',url:'http://192.168.1.20:15300'})
     expect(t.store.require<any>('parent-user','agent',agent.id).nodeId).toBe(node.id)
-    const target=t.nodes.target('parent-user',node.id)
-    expect(target.url.hostname).toBe('192.168.1.20')
-    const profiles=await target.session.request('/api/profiles'); expect(profiles.status).toBe(200)
-    const gateway=new WorkspaceGateway(target)
-    await gateway.connect()
-    expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
-    gateway.close()
-    const command=t.seen.find(r=>r.url.pathname.endsWith('/commands'))!
-    expect(command.headers.get('idempotency-key')).toBeTruthy()
-    expect(command.body.method).toBe('session.create')
-    expect(t.seen.every(r=>r.url.port==='15300')).toBe(true)
-    expect(t.seen.some(r=>r.url.pathname.includes('/auth/password-login'))).toBe(false)
+    expect(()=>t.nodes.target('parent-user',node.id)).toThrow('远程机器人已停用')
+    expect(t.seen.some(r=>r.url.pathname.endsWith('/commands'))).toBe(false)
   })
-  it('recovers a lost command response by receipt without resubmitting the command', async () => {
+  it('refuses retired paired-node execution before issuing remote commands', async () => {
     const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
     const id=t.store.list<WorkspaceNode>('owner','node')[0]!.id
-    const gateway=new WorkspaceGateway(t.nodes.target('owner',id)); await gateway.connect(); t.loseResponse()
-    expect(await gateway.rpc('session.create',{profile:'remote-profile'})).toMatchObject({stored_session_id:'stored-child'})
-    gateway.close()
-    expect(t.seen.filter(r=>r.url.pathname.endsWith('/commands'))).toHaveLength(1)
-    expect(t.seen.filter(r=>r.url.pathname.includes('/api/realtime/commands/'))).toHaveLength(1)
+    t.seen.length=0
+    expect(()=>t.nodes.target('owner',id)).toThrow('远程机器人已停用')
+    expect(t.seen).toEqual([])
   })
   it('rejects self-pairing before redeeming a code', async () => {
     const t=setup(remote)
     await expect(t.nodes.pair('owner',{qrPayload:code(),name:'self'})).rejects.toThrow('自己')
     expect(t.seen).toEqual([])
   })
-  it('imports a read-only reference without duplicates and refreshes remote configuration', async () => {
+  it('refuses importing retired remote agents without creating local references', async () => {
     const t=setup();await t.nodes.pair('owner',{qrPayload:code(),name:'child'})
     const node=t.store.list<WorkspaceNode>('owner','node')[0]!, remote='66666666-6666-4666-8666-666666666666'
-    const agent=await t.nodes.importRemoteAgent('owner',node.id,remote)
-    expect(agent.remoteAgentId).toBe(remote);expect(agent.nodeId).toBe(node.id)
-    expect(()=>t.store.updateAgent('owner',agent.id,{instructions:'local edit'})).toThrow('远端')
-    expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).id).toBe(agent.id)
-    t.changeRemoteRules();await t.nodes.remoteAgents('owner',node.id,true)
-    expect((await t.nodes.refreshRemoteAgent('owner',agent)).instructions).toBe('remote rules v2')
-    t.store.updateAgent('owner',agent.id,{archived:true})
-    expect((await t.nodes.importRemoteAgent('owner',node.id,remote)).archived).toBe(false)
-    expect(t.store.list('owner','agent')).toHaveLength(1)
-    expect(t.nodes.targetForAgent('owner',agent).url.pathname).toContain(`/api/workspace-agents/${remote}/gateway`)
+    t.seen.length=0
+    await expect(t.nodes.importRemoteAgent('owner',node.id,remote)).rejects.toThrow('远程机器人已停用')
+    expect(t.store.list('owner','agent')).toEqual([])
+    expect(t.seen).toEqual([])
   })
   it('checks identity at the new address before sending the saved bearer and leaves state unchanged on mismatch', async () => {
     const t=setup(); await t.nodes.pair('owner',{qrPayload:code(),name:'远端'})