Parcourir la source

发布 v0.3.30:完善聊天增量同步并修复会话分页排序

LSamien il y a 1 mois
Parent
commit
9e6d397e4b

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.29**,配套 iOS **1.3(188)**。详见 [发布说明](docs/releases/v0.3.29.md)。
+当前发布版本:**v0.3.30**,配套 iOS **1.3(188)**。详见 [发布说明](docs/releases/v0.3.30.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 

+ 7 - 0
docs/releases/v0.3.30.md

@@ -0,0 +1,7 @@
+# v0.3.30
+
+- 普通聊天历史优先从 Web 的 SQLite 缓存读取,并按上游变化增量同步,减少重复拉取并保持最新对话排序。
+- 修复会话活动时间字段合并和分页元数据缓存,避免首次加载与后续翻页之间顺序变化、会话遗漏。
+- 完善聊天模式的跨端运行状态订阅与断线重连恢复,其他设备发起或继续的任务可及时反映到当前页面。
+- 修复 Hermes 工作区文件卡识别、预览与下载,并兼容分页响应缺少总数或结束标记的情况。
+- 统一更新提示和部署文档,补充 Docker 安装、配置与升级说明。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.29",
+  "version": "0.3.30",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.29",
+      "version": "0.3.30",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.29",
+  "version": "0.3.30",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.29",
-  "webVersion": "0.3.29",
-  "gitTag": "v0.3.29"
+  "releaseVersion": "0.3.30",
+  "webVersion": "0.3.30",
+  "gitTag": "v0.3.30"
 }

+ 7 - 5
src/server/chatCache.ts

@@ -349,7 +349,8 @@ export class ChatCacheStore {
   }
 
   putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
-    response: UpstreamResponse, ownedList = false, requestStartedAt = Date.now()): boolean {
+    response: UpstreamResponse, ownedList = false, requestStartedAt = Date.now(),
+    metadataResponse: UpstreamResponse = response): boolean {
     const payload = parseResponse(response)
     if (!payload) return false
     if (kind === 'list' && !ownedList) return false
@@ -358,7 +359,9 @@ export class ChatCacheStore {
     const now = Date.now()
     this.db.exec('SAVEPOINT chat_snapshot')
     try {
-      this.ingestPayload(owner, profile, sessionID, payload, now, kind, requestStartedAt)
+      // Hydrate all registered sessions from the unsliced upstream list, while
+      // persisting only the owner-filtered page as the public response snapshot.
+      this.ingestPayload(owner, profile, sessionID, parseResponse(metadataResponse) ?? payload, now, kind, requestStartedAt)
       this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
         VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
         body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
@@ -615,8 +618,7 @@ export class ChatCacheStore {
       if (row) {
         const title = row.authoritative_title?.trim()
         merged.set(key, {
-          ...merged.get(key),
-          ...session,
+          ...mergeSessionMetadata(merged.get(key) ?? {}, session),
           ...(title ? { title } : {}),
           _yaoyao_registry_activity: row.last_synced_at,
           owned: true,
@@ -963,7 +965,7 @@ export class ChatCacheCoordinator {
             listPage,
           )
         : upstreamResponse
-      this.store.putSnapshot(owner, key, kind, profile, sessionID, response, ownedList, requestStartedAt)
+      this.store.putSnapshot(owner, key, kind, profile, sessionID, response, ownedList, requestStartedAt, upstreamResponse)
       return { response, source: 'upstream', state: 'current' }
     } catch (error) {
       if (this.mode === 'prefer-local' && local) return { response: projectedLocal!.response, source: 'local', state: 'stale' }

+ 1 - 1
src/server/routes.ts

@@ -766,7 +766,7 @@ async function cachedChatRead(
       : upstreamResponse
     dependencies.chatCache.store.putSnapshot(
       user.id, options.key, options.kind, options.profile,
-      options.sessionID, response, options.ownedList, requestStartedAt,
+      options.sessionID, response, options.ownedList, requestStartedAt, upstreamResponse,
     )
     ctx.set('X-Yaoyao-Data-Source', 'upstream')
     ctx.set('X-Yaoyao-Sync-State', 'current')

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

@@ -927,10 +927,11 @@ test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ pag
   await expect(page.locator('.desktop-sidebar .sidebar-item--single-line').first().locator('.app-icon path')).toHaveAttribute('fill', 'currentColor')
   await expect(sessionMenu.getByText('重命名', { exact: true })).toBeVisible()
   await expect(sessionMenu.getByText('删除会话', { exact: true })).toBeVisible()
+  await expect(sessionMenu.getByRole('menuitem', { name: '强制刷新历史' })).toBeVisible()
   await expect(page.getByText('置顶会话', { exact: true })).toHaveCount(0)
   const menuBox = await sessionMenu.evaluate(element => element.getBoundingClientRect().toJSON())
   expect(menuBox.width).toBeLessThanOrEqual(220)
-  expect(menuBox.height).toBeLessThan(165)
+  expect(menuBox.height).toBeLessThan(200)
   await expect(page.locator('.session-action-layer')).toHaveCount(0)
   await sessionMenu.getByText('取消置顶', { exact: true }).click()
   await expect(page.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toHaveCount(0)

+ 29 - 0
tests/server/chatCache.test.ts

@@ -103,6 +103,35 @@ describe('durable source=web chat cache', () => {
     f.store.close()
   })
 
+  it('hydrates all owned list metadata before paging and keeps upstream activity aliases authoritative', async () => {
+    const f = fixture()
+    const sessions = Array.from({ length: 103 }, (_, index) => ({
+      id: `owned-${index}`, profile, source: 'web', title: `Conversation ${index}`,
+      started_at: 1_700_000_000 - index, last_active_at: 1_700_000_100 - index,
+      message_count: 0, pinned: index === 102,
+    }))
+    for (const session of sessions) {
+      f.store.recordCommand(owner, profile, session.id, 'session.create', { source: 'web' })
+    }
+    const load = vi.fn(async () => response({ sessions: [
+      { id: 'unowned-history', profile, source: 'web', title: 'Private native history' }, ...sessions,
+    ] }))
+    const first = await f.coordinator.read(owner, 'first', 'list', profile, undefined, load, true, { limit: 100 })
+    const firstPage = JSON.parse(first.response.body.toString())
+    expect(firstPage.sessions[0]).toMatchObject({ id: 'owned-102', last_active: sessions[102]!.last_active_at })
+    expect(firstPage.sessions[1]).toMatchObject({ id: 'owned-0', last_active: sessions[0]!.last_active_at })
+    expect(f.store.needsListMetadata(owner, profile)).toBe(false)
+    const second = await f.coordinator.read(owner, 'second', 'list', profile, undefined, load, true, { offset: 100, limit: 100 })
+    expect(second.source).toBe('local')
+    expect(load).toHaveBeenCalledTimes(1)
+    const secondPage = JSON.parse(second.response.body.toString())
+    const ids = [...firstPage.sessions, ...secondPage.sessions].map((session: { id: string }) => session.id)
+    expect(ids).toEqual(['owned-102', ...sessions.slice(0, 102).map(session => session.id)])
+    expect(f.store.snapshot(owner, 'first')!.response.body.toString()).not.toContain('unowned-history')
+    expect(f.store.localDetail(owner, profile, 'unowned-history')).toBeUndefined()
+    f.store.close()
+  })
+
   it('serves a warm list without a second 9119 request and survives restart', async () => {
     const f = fixture()
     f.store.recordRoute(owner, profile, sessionID, 'runtime-web')