Эх сурвалжийг харах

发布 v0.4.19:升级普通聊天 v2 并修复发送与检查点恢复

Samien 1 сар өмнө
parent
commit
708eceb682

+ 6 - 6
README.md

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

+ 49 - 0
docs/releases/v0.4.19.md

@@ -0,0 +1,49 @@
+# 夭夭 AI v0.4.19
+
+本次发布 Web、macOS Apple Silicon 桌面版、Android 1.3(171)及 Docker Web 双架构镜像。
+
+## 更新内容
+
+- 普通聊天正式使用服务端统一消息协议 v2。流式输出、历史恢复和上游消息编号变化时保持稳定的显示身份,明确区分输入、回合、正文片段与工具调用,减少重复消息和内容覆盖。
+- 消息、事件收据与重放日志在同一事务中保存。断线后从已保存位置继续,发现事件缺口或会话代次变化时重新同步;分页不会覆盖更新的消息或恢复已经删除的记录,保存失败不推进恢复位置。
+- 修复普通聊天发送前本地缓存写入失败,以及刷新后未恢复运行和待处理交互状态的问题。缓存保存独立快照,恢复检查点时同步还原界面状态。
+- 已有普通聊天记录通过后台队列核验,运行中的会话延后处理。切换前保留历史、消息及关联记录的存档;无法确认归属的已接收输入保留并等待核验,不按文本相同删除消息。
+- 本地虚拟机新增“本机协作模式”,在已有本机会话和授权工具的基础上加入虚拟机操作,支持显式复制文件与回传产物。旧记录仍使用原虚拟机模式,任务或人工接管期间不能切换,临时助手和新成员不自动继承该模式。
+- 修复执行节点协作模式能力握手被过滤的问题,配套节点可正确显示和启用本机协作能力。
+- 桌面启动页和应用菜单新增“使用当前 App 覆盖同版本 Web”。需要用户主动选择,执行前校验完整性、归属与空闲状态并备份,失败时恢复;忙碌时延后,不中断当前任务。
+- Android 1.3(171)同步接入普通聊天 v2,持久保存消息与恢复检查点,并保留待确认输入。
+
+## 升级要求
+
+**普通聊天存在协议兼容性变更,请将服务端和客户端配套升级。**
+
+- Web/macOS 使用 v0.4.19,Android 使用 1.3(171)。其他移动端也必须具备 v2 支持;旧客户端普通聊天会收到升级提示,新客户端不再回退到旧消息协议。升级服务端前请确认所使用的客户端均已支持 v2。
+- v2 是正式代码路径,v1 试验开关不再控制普通聊天。Bot 模式及原生机器人通道保持各自原有协议边界。
+- 升级前备份 `.yaoyao` 数据与密钥。旧数据核验将在新服务启动后后台执行;需要回退时使用配套备份恢复流程,避免直接让旧程序读取升级后的数据。
+- 本机协作模式需要更新配套执行节点,并在基础会话中启用所需工具桥。固定桌面的数量和生命周期继续由原部署配置管理。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.19-arm64.dmg`:macOS Apple Silicon,Apple Development 开发签名,未公证。
+- `Yaoyao-1.3-171-android.apk`:Android 12 及以上,正式签名。源码提交 `3c65d94`。
+- `Yaoyao-0.4.19-source.tar.gz`:Web/桌面源码及部署配置。
+- `SHA256SUMS.txt`:以上附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.19-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.19` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 版本镜像验证后指向同一通用镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.19
+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.19`。独立电脑镜像沿用现有配置,保留旧版本标签与附件。Docker 镜像发布到 Docker Hub,GitHub 不上传镜像归档。
+
+最终附件摘要、镜像摘要和运行验证记录发布在 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.19)。模拟器和协议测试不能替代实体设备后台挂起及蜂窝/Wi-Fi 切换验收,这些真机场景尚未完成验证。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.18",
-  "webVersion": "0.4.18",
-  "gitTag": "v0.4.18"
+  "releaseVersion": "0.4.19",
+  "webVersion": "0.4.19",
+  "gitTag": "v0.4.19"
 }

+ 7 - 0
src/client/api/chatTranscript.ts

@@ -187,14 +187,21 @@ export class ChatTranscriptClient {
   }
   async run() {
     let needsSnapshot = !this.snapshot
+    let restorePending = Boolean(this.snapshot)
     while (!this.abort.signal.aborted) {
       let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
       let watchdog: ReturnType<typeof setTimeout> | undefined
       try {
+        if (restorePending) {
+          const saved = this.snapshot!
+          await this.serial(() => this.commit(saved))
+          restorePending = false
+        }
         if (needsSnapshot) {
           await this.hydrate()
           needsSnapshot = false
         }
+        if (this.abort.signal.aborted) return
         const current = this.snapshot
         if (!current) return
         const attempt = new AbortController()

+ 6 - 3
src/client/utils/cache.ts

@@ -31,14 +31,17 @@ export class ScopedCache<T> {
       if(durable)throw new Error('聊天缓存超出容量,未推进同步位置')
       return
     }
+    // Cache records are JSON snapshots. Detach reactive proxies before IndexedDB
+    // structured-clones them, and keep the memory fallback equally immutable.
+    const snapshot = JSON.parse(serialized) as T
     const storageKey = this.key(scope, key)
     const db = await database()
     if(durable&&typeof indexedDB!=='undefined'&&!db)throw new Error('聊天缓存不可写,未推进同步位置')
     if (db) {
-      if(durable)await db.put('records',value,storageKey)
-      else await db.put('records', value, storageKey).catch(() => undefined)
+      if(durable)await db.put('records',snapshot,storageKey)
+      else await db.put('records', snapshot, storageKey).catch(() => undefined)
     }
-    memory.set(storageKey, value)
+    memory.set(storageKey, snapshot)
   }
 
   async delete(scope: string, key: string): Promise<void> {

+ 22 - 0
tests/client/ordinaryTranscriptV2.test.ts

@@ -87,4 +87,26 @@ describe('ordinary v2 durable Web replica', () => {
       vi.useRealTimers()
     }
   })
+  it('restores running state and interactions before resuming the saved event cursor', async () => {
+    const { client, changed } = setup()
+    const saved = { ...fixture.snapshot, running: true, liveStatus: '正在思考', pendingApproval: { id: 'approval' } }
+    client.restore(saved)
+    const fetchMock = vi.fn((_url: unknown, options: RequestInit) => {
+      expect(changed).toHaveBeenCalledWith(saved)
+      return new Promise((_resolve, reject) => {
+        options.signal?.addEventListener('abort', () => reject(new Error('closed')), { once: true })
+      })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const running = client.run()
+    try {
+      await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+      expect(api.request).not.toHaveBeenCalled()
+      expect(fetchMock.mock.calls[0]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
+    } finally {
+      client.close()
+      await running
+      vi.unstubAllGlobals()
+    }
+  })
 })

+ 40 - 0
tests/client/scopedCache.test.ts

@@ -0,0 +1,40 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { reactive } from 'vue'
+
+const storage = vi.hoisted(() => ({ records: new Map<string, unknown>(), fail: false }))
+vi.mock('idb', () => ({ openDB: async () => ({
+  put: async (_store: string, value: unknown, key: string) => {
+    if (storage.fail) throw new Error('disk unavailable')
+    storage.records.set(key, structuredClone(value))
+  },
+  get: async (_store: string, key: string) => {
+    if (storage.fail) throw new Error('disk unavailable')
+    return structuredClone(storage.records.get(key))
+  },
+}) }))
+
+afterEach(() => { storage.records.clear(); storage.fail = false; vi.unstubAllGlobals(); vi.resetModules() })
+
+it('durably stores reactive pending messages as detached snapshots', async () => {
+  vi.stubGlobal('indexedDB', {})
+  const { ScopedCache } = await import('../../src/client/utils/cache')
+  const cache = new ScopedCache<{ messages: Array<{ content: string }>; cursor: number }>('reactive')
+  const pending = reactive({ content: 'pending input' })
+  const value = reactive({ messages: [pending], cursor: 3 })
+  await cache.set('account', 'session', value, true)
+  pending.content = 'later edit'
+  value.cursor = 4
+  expect(await cache.get('account', 'session')).toEqual({ messages: [{ content: 'pending input' }], cursor: 3 })
+})
+
+it('does not advance the memory checkpoint when durable storage fails', async () => {
+  vi.stubGlobal('indexedDB', {})
+  const { ScopedCache } = await import('../../src/client/utils/cache')
+  const cache = new ScopedCache<{ cursor: number }>('failed-write')
+  const value = reactive({ cursor: 3 })
+  await cache.set('account', 'session', value, true)
+  storage.fail = true
+  value.cursor = 4
+  await expect(cache.set('account', 'session', value, true)).rejects.toThrow('disk unavailable')
+  expect(await cache.get('account', 'session')).toEqual({ cursor: 3 })
+})

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

@@ -168,7 +168,7 @@ test('an idle Web viewer follows an iOS-origin run and restores thinking after r
   await login(page)
   const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
   const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
-  const opened = await page.request.post('/api/realtime/channels', { headers, data: { channel: 'chat' } })
+  const opened = await page.request.post('/api/realtime/channels', { headers, data: { channel: 'chat', chatProtocol: 'ordinary-chat-transcript-v2' } })
   expect(opened.ok(), await opened.text()).toBe(true)
   const channel = (await opened.json()).id
   let sequence = 0

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

@@ -706,7 +706,7 @@ test.skip('selects existing group topics and creates a new protocol v4 topic on
 
 test('renders persisted user file markers as attachment cards', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
-  const userMessage = page.locator('[data-message-id="message-user-file"]')
+  const userMessage = page.locator('.message--user').filter({ hasText: '查看附件' })
   await expect(userMessage).toContainText('查看附件')
   await expect(userMessage.getByRole('button', { name: /测试报告\.docx/ })).toBeVisible()
   await expect(userMessage).not.toContainText('@file:')
@@ -715,7 +715,7 @@ test('renders persisted user file markers as attachment cards', async ({ page })
 
 test('renders persisted user image markers as direct images instead of file cards', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
-  const userMessage = page.locator('[data-message-id="message-user-image"]')
+  const userMessage = page.locator('.message--user').filter({ has: page.locator('img[alt="测试图片.png"]') })
   const imageAttachment = userMessage.locator('.message__attachment--image')
   await expect(imageAttachment).toBeVisible()
   await expect(imageAttachment.locator('img[alt="测试图片.png"]')).toBeVisible()
@@ -782,7 +782,7 @@ test('renders subtask completion as a collapsed timeline event', async ({ page }
 
 test('renders background process exits as collapsed system events', async ({ page }) => {
   await page.goto('/chat/session-demo')
-  const event = page.locator('[data-message-id="message-background-process"] .background-process-event')
+  const event = page.locator('.background-process-event')
   const detail = event.locator('.markdown')
   await expect(event).toContainText('后台子任务已终止 · SIGTERM')
   await expect(event).not.toHaveAttribute('open', '')
@@ -790,19 +790,19 @@ test('renders background process exits as collapsed system events', async ({ pag
   await event.locator('summary').click()
   await expect(detail).toBeVisible()
   await expect(detail).toContainText('Command: ./run_mac.sh')
-  await expect(page.locator('[data-message-id="message-background-process"]')).toHaveClass(/message--system/)
+  await expect(page.locator('.message').filter({ has: page.locator('.background-process-event') })).toHaveClass(/message--system/)
 })
 
 test('renders context compaction as a collapsed timeline event', async ({ page }) => {
   await page.goto('/chat/session-demo')
-  const compaction = page.locator('[data-message-id="message-compaction"] .compaction-event')
+  const compaction = page.locator('.compaction-event')
   await expect(compaction).toContainText('上下文已压缩')
   await expect(compaction).toContainText('压缩摘要已归档 · 点击查看')
   await expect(compaction).not.toHaveAttribute('open', '')
   await expect(compaction.getByText('Historical Task Snapshot', { exact: true })).toBeHidden()
   await compaction.locator('summary').click()
   await expect(compaction.getByText('Historical Task Snapshot', { exact: true })).toBeVisible()
-  await expect(page.locator('[data-message-id="message-compaction"]')).toHaveClass(/message--system/)
+  await expect(page.locator('.message').filter({ has: page.locator('.compaction-event') })).toHaveClass(/message--system/)
 })
 
 test('renders model changes as a collapsed system event', async ({ page }) => {
@@ -928,7 +928,7 @@ test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ pag
   await expect(outline.getByText('请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', { exact: true })).toBeVisible()
   await expect(outline.getByText('验收摘要', { exact: true })).toBeVisible()
   await outline.getByRole('button', { name: '跳转到 验收摘要' }).click()
-  await expect(page.locator('[data-message-id="message-assistant"]')).toHaveClass(/message--revealed/)
+  await expect(page.locator('.message--assistant').filter({ hasText: '验收摘要' })).toHaveClass(/message--revealed/)
   await page.getByRole('button', { name: '关闭会话大纲' }).click()
   await expect(outline).toBeHidden()
   await page.getByRole('button', { name: '会话操作' }).click()
@@ -1070,15 +1070,15 @@ test('renders the Kanban snapshot and mobile status control without page overflo
   expect(geometry.board).toBeGreaterThan(geometry.viewport)
 })
 
-test('serves cached chat history locally and refreshes upstream only on explicit action', async ({ page }, testInfo) => {
-  const messagesURL = (url: string) => url.includes('/api/app/sessions/session-demo/messages')
+test('serves canonical chat snapshots and supports explicit history verification', async ({ page }, testInfo) => {
+  const messagesURL = (url: string) => url.includes('/api/app/chat/sessions/session-demo/snapshot')
   const initial = page.waitForResponse(response => messagesURL(response.url()))
   await page.goto('/chat/session-demo?profile=yaoyao')
   expect((await initial).ok()).toBe(true)
   await expect(page.getByRole('button', { name: '会话操作', exact: true })).toBeVisible()
-  const restored = page.waitForResponse(response => messagesURL(response.url()))
+  const restored = page.waitForResponse(response => response.url().includes('/api/app/chat/sessions/session-demo/events?'))
   await page.reload()
-  expect((await restored).headers()['x-yaoyao-data-source']).toBe('local')
+  expect((await restored).headers()['content-type']).toContain('text/event-stream')
   await page.getByRole('button', { name: '会话操作', exact: true }).click()
   await expect(page.getByRole('menuitem', { name: '强制刷新历史' })).toBeVisible()
   await expect(page.getByRole('menu', { name: '会话操作' })).toHaveCSS('opacity', '1')