Просмотр исходного кода

统一 Bot 会话列表的置顶与最新消息排序

Samien 1 месяц назад
Родитель
Сommit
b92b31ebf7

+ 25 - 0
docs/bot-conversation-order.md

@@ -0,0 +1,25 @@
+# Bot 模式会话列表排序
+
+Web、桌面、iOS、Android 共用以下规则:
+
+1. `pinned` 为 true 的会话优先。
+2. 同一置顶分组内,按 `lastMessageAt` 倒序;缺失时使用 `createdAt`,数值 0 不算缺失。
+3. 时间相同按会话 ID 升序。服务端 UUID 使用确定性的字符顺序,不使用用户界面语言相关排序。
+
+列表时间也取相同的会话时间,不使用配置修改时间 `updatedAt`,也不使用本机当前选择的群聊任务时间。任务选择与文字预览仍可保留本地状态。
+
+## 执行位置
+
+- 服务端常规列表与首次/重连快照复用 `compareWorkspaceConversations`。
+- Web 与桌面共享 `WorkspaceTranscriptStore`,每次替换会话数组均排序;页面读取排序后的数组。
+- iOS 在快照、刷新、会话增量事件三处执行 `WebWorkspaceConversation.precedes`。
+- Android 在快照、刷新、会话增量事件三处执行 `workspaceConversationOrder`。
+- 三端刷新期间若游标已因新事件推进,丢弃旧列表响应,避免回退刚同步的置顶或时间。
+- 同步范围仍由服务端与账号决定;本次不修改账号隔离、置顶持久化或事件协议。
+
+## 验证
+
+- Web:40 项相关测试通过;服务端测试同时检查列表接口、快照接口和两个独立状态仓库接收置顶/新消息事件后的顺序。客户端覆盖反向初始数组、时间并列、空会话、时间为 0、置顶/取消置顶、新会话与重复事件。
+- iOS:51 项 WorkspaceChatTests 通过,覆盖快照与刷新一致、增量重排、新会话、旧任务时间不影响列表、旧刷新响应不能覆盖新置顶事件。
+- Android:51 项 core:model 测试通过,包含同一组跨端排序数据;app Debug Kotlin 编译通过。
+- Web 生产构建通过。以上为代码、隔离用例和构建验证,未修改线上会话,未发布新版本或安装到真实设备。

+ 6 - 1
src/client/components/workspace/transcriptStore.ts

@@ -1,4 +1,5 @@
 import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
+import { compareWorkspaceConversations } from '@shared/workspace'
 import type { WorkspaceAgent, WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceMessage, WorkspaceRun, WorkspaceTask } from '@shared/workspace'
 
 export interface WorkspaceDetail {
@@ -60,7 +61,11 @@ export function foldDetail(detail: WorkspaceDetail, event: WorkspaceEvent): Work
 
 export class WorkspaceTranscriptStore {
   agents: WorkspaceAgent[] = []
-  conversations: WorkspaceConversation[] = []
+  private conversationRows: WorkspaceConversation[] = []
+  get conversations(): WorkspaceConversation[] { return this.conversationRows }
+  set conversations(rows: WorkspaceConversation[]) {
+    this.conversationRows = [...rows].sort(compareWorkspaceConversations)
+  }
   readonly details = new Map<string, WorkspaceDetail>()
   cursor = 0
   readonly selectedTasks = new Map<string, string>()

+ 2 - 2
src/client/components/workspace/viewModels.ts

@@ -1,5 +1,5 @@
 import { visibleMessageText, messageReasoningText } from '@shared/messageFiles'
-import { workspaceHasUnread } from '@shared/workspace'
+import { workspaceConversationTimestamp, workspaceHasUnread } from '@shared/workspace'
 import { toolResultFailed } from '@shared/chatTools'
 import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
 import type {
@@ -449,7 +449,7 @@ export function workspaceConversationItem(c: import('@shared/workspace').Workspa
     id: c.id, title: c.name, subtitle: c.preview || '开始聊天', pinned: c.pinned,
     section: c.pinned ? '已置顶' : '聊天', avatar: c.kind === 'group' ? '' : c.avatar,
     avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, agents, c) : [],
-    meta: formatConversationTime(c.lastMessageAt ?? c.createdAt),
+    meta: formatConversationTime(workspaceConversationTimestamp(c)),
     avatarKind: c.kind === 'direct' ? 'agent' : 'team',
     avatarState: activity ?? workspaceAvatarState(c, c.memberIds[0] || ''), avatarActivityKey: c.lastSeq,
     unreadDot: workspaceHasUnread(c), status: activity || c.activeRunId ? 'working' : undefined,

+ 3 - 3
src/client/views/ConversationsView.vue

@@ -251,13 +251,13 @@ async function refresh() {
   if (disposed || auth.user?.id !== owner || transcriptStore.cursor !== startedAtCursor) return
   agents.value = a.agents
   const localConversations = new Map(conversations.value.map(row => [row.id, row]))
-  conversations.value = c.conversations.map(conversation => {
+  transcriptStore.conversations = c.conversations.map(conversation => {
     const local = localConversations.get(conversation.id)
     return local && local.unreadVersion === conversation.unreadVersion && [...pendingReads].some(key => key.startsWith(`${conversation.id}:`))
       ? { ...conversation, unread: local.unread, unreadCount: local.unreadCount } : conversation
   })
   transcriptStore.agents = agents.value
-  transcriptStore.conversations = conversations.value
+  conversations.value = transcriptStore.conversations
   if (!cursor) { cursor = c.cursor; transcriptStore.cursor = c.cursor }
 }
 async function load(id = selected.value, append = false) {
@@ -458,7 +458,7 @@ async function hydrateWorkspace() {
   const snapshot = await apiRequest<WorkspaceSnapshot & { serverIdentity?: ServerIdentity }>('/api/app/workspace/snapshot')
   if (disposed || auth.user?.id !== owner) return
   transcriptStore.hydrate(snapshot)
-  agents.value = snapshot.agents; conversations.value = snapshot.conversations; cursor = snapshot.cursor
+  agents.value = snapshot.agents; conversations.value = transcriptStore.conversations; cursor = snapshot.cursor
   publishServerIdentity(snapshot.serverIdentity)
 }
 function connectEvents() {

+ 4 - 7
src/server/workspaceRoutes.ts

@@ -18,6 +18,7 @@ import { receiveGroupUploads, type UploadStore } from './uploads.js'
 import { HttpError } from './errors.js'
 import type { PushCoordinator } from './pushCoordinator.js'
 import type { LocalAuthStore } from './localAuth.js'
+import { compareWorkspaceConversations } from '../shared/workspace.js'
 import type {
   WorkspaceMessage,
   WorkspaceAgent,
@@ -155,12 +156,7 @@ export function workspaceRouter(
       conversations: store
         .list<WorkspaceConversation>(user, 'conversation')
         .map(c => store.conversationSummary(user, c))
-        .sort(
-          (a, b) =>
-            Number(b.pinned) - Number(a.pinned) ||
-            (b.lastMessageAt ?? b.createdAt) - (a.lastMessageAt ?? a.createdAt) ||
-            a.id.localeCompare(b.id),
-        ),
+        .sort(compareWorkspaceConversations),
       cursor: store.cursor(user),
     }
   })
@@ -313,7 +309,8 @@ export function workspaceRouter(
   })
   router.get('/api/app/workspace/snapshot', ctx => {
     const user = owner(ctx)
-    const conversations = store.list<WorkspaceConversation>(user, 'conversation').map(c => store.conversationSummary(user, c))
+    const conversations = store.list<WorkspaceConversation>(user, 'conversation')
+      .map(c => store.conversationSummary(user, c)).sort(compareWorkspaceConversations)
     const details = conversations.filter(c => !c.archived).map(c => workspaceDetail(store, runtime, user, c.id))
     ctx.set('Cache-Control', 'no-store')
     ctx.body = { agents: store.list<WorkspaceAgent>(user, 'agent').map(a => store.agentSummary(a)),

+ 10 - 0
src/shared/workspace.ts

@@ -64,6 +64,16 @@ export interface WorkspaceConversation {
   createdAt: number
   updatedAt: number
 }
+/** Shared by the HTTP lists, snapshots and Web/desktop incremental state. */
+export function workspaceConversationTimestamp(value: Pick<WorkspaceConversation, 'lastMessageAt' | 'createdAt'>): number {
+  return value.lastMessageAt ?? value.createdAt
+}
+export function compareWorkspaceConversations(a: WorkspaceConversation, b: WorkspaceConversation): number {
+  return Number(b.pinned) - Number(a.pinned)
+    || workspaceConversationTimestamp(b) - workspaceConversationTimestamp(a)
+    || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
+}
+
 export interface WorkspaceTask {
   id: string
   conversationId: string

+ 34 - 0
tests/client/workspaceTranscript.test.ts

@@ -8,6 +8,40 @@ const detail = (): WorkspaceDetail => ({ conversation, messages: [message('old',
 const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
 
 describe('Bot transcript cache', () => {
+  it('keeps independent clients ordered after snapshots, pin changes and new activity', () => {
+    const rows: WorkspaceConversation[] = [
+      { ...conversation, id: 'tie-b', lastMessageAt: 200 },
+      { ...conversation, id: 'zero', lastMessageAt: 0, createdAt: 5000 },
+      { ...conversation, id: 'old', lastMessageAt: 100, updatedAt: 99999 },
+      { ...conversation, id: 'pin', pinned: true, lastMessageAt: 50 },
+      { ...conversation, id: 'new', lastMessageAt: 400 },
+      { ...conversation, id: 'tie-a', lastMessageAt: 200 },
+      { ...conversation, id: 'fallback', lastMessageAt: undefined, createdAt: 300 },
+    ]
+    const before = rows.map(c => c.id)
+    const clients = [new WorkspaceTranscriptStore(), new WorkspaceTranscriptStore()]
+    clients[0]!.hydrate({ agents: [], conversations: rows, details: [], cursor: 0 })
+    clients[1]!.hydrate({ agents: [], conversations: [...rows].reverse(), details: [], cursor: 0 })
+    const ordered = (expected: string[]) => clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(expected))
+    ordered(['pin', 'new', 'fallback', 'tie-a', 'tie-b', 'old', 'zero'])
+    expect(rows.map(c => c.id)).toEqual(before)
+    const change = (seq: number, id: string, patch: Partial<WorkspaceConversation>) => clients.forEach(client => {
+      const current = client.conversations.find(c => c.id === id) ?? { ...conversation, id }
+      client.apply({ seq, type: 'conversation.changed', conversationId: id, data: { ...current, ...patch } })
+    })
+    change(1, 'old', { lastMessageAt: 500 })
+    ordered(['pin', 'old', 'new', 'fallback', 'tie-a', 'tie-b', 'zero'])
+    change(2, 'tie-b', { pinned: true })
+    ordered(['tie-b', 'pin', 'old', 'new', 'fallback', 'tie-a', 'zero'])
+    change(3, 'pin', { pinned: false })
+    change(4, 'new-2', { lastMessageAt: 400 })
+    ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
+    change(3, 'pin', { pinned: true })
+    ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
+    clients[0]!.conversations = [...clients[1]!.conversations].reverse()
+    ordered(['tie-b', 'old', 'new', 'new-2', 'fallback', 'tie-a', 'pin', 'zero'])
+  })
+
   it('returns a warm transcript without losing its older pages and preserves settled identities on a live update', () => {
     const store = new WorkspaceTranscriptStore(), initial = detail()
     store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })

+ 30 - 0
tests/server/workspaceRoutes.test.ts

@@ -10,6 +10,7 @@ import { createApplication, type ApplicationRuntime } from '../../src/server/app
 import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
+import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
 
 let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
 const first: LocalUser = {
@@ -95,6 +96,35 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it('uses the same ordering for snapshots, list refreshes and pin/message events', async () => {
+    const store = runtime.workspace
+    store.createAgent('first', { name: '排序验证', profile: 'default' })
+    const base = store.list<import('../../src/shared/workspace').WorkspaceConversation>('first', 'conversation')[0]!
+    store.remove('first', 'conversation', base.id)
+    for (const item of [
+      { id: 'a-old', lastMessageAt: 100, pinned: false },
+      { id: 'b-new', lastMessageAt: 200, pinned: false },
+      { id: 'z-pin', lastMessageAt: 50, pinned: true },
+    ]) store.put('first', 'conversation', item.id, { ...base, ...item })
+    const snapshot = (await req('get', '/api/app/workspace/snapshot').expect(200)).body
+    const list = (await req('get', '/api/app/conversations').expect(200)).body.conversations
+    expect(snapshot.conversations.map((c: { id: string }) => c.id)).toEqual(['z-pin', 'b-new', 'a-old'])
+    expect(list.map((c: { id: string }) => c.id)).toEqual(snapshot.conversations.map((c: { id: string }) => c.id))
+    const clients = [new WorkspaceTranscriptStore(), new WorkspaceTranscriptStore()]
+    clients.forEach(client => client.hydrate(snapshot))
+    const stop = store.observe((owner, event) => { if (owner === 'first') clients.forEach(client => client.apply(event)) })
+    try {
+      store.saveMessage('first', { id: randomUUID(), conversationId: 'a-old', seq: 0, role: 'assistant',
+        content: '新的回复', reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 300 })
+      clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(['z-pin', 'a-old', 'b-new']))
+      await req('patch', '/api/app/conversations/a-old').send({ pinned: true }).expect(200)
+      const expected = ['a-old', 'z-pin', 'b-new']
+      clients.forEach(client => expect(client.conversations.map(c => c.id)).toEqual(expected))
+      expect((await req('get', '/api/app/workspace/snapshot').expect(200)).body.conversations.map((c: { id: string }) => c.id)).toEqual(expected)
+      expect((await req('get', '/api/app/conversations').expect(200)).body.conversations.map((c: { id: string }) => c.id)).toEqual(expected)
+    } finally { stop() }
+  })
+
   it.each(['direct', 'group'] as const)('uses versioned unread acknowledgments for %s without recounting history', async kind => {
     const store = runtime.workspace
     const first = store.createAgent('first', { name: '未读甲', profile: 'default' })