refactor: unify session list/detail queries into @openchatlab/core

Extract duplicated session info assembly from electron/main/worker/query/sessions.ts,
packages/server/src/http/routes/web.ts, and sessions.ts into shared core functions.

- Add CoreSessionInfo type covering all single-DB queryable AnalysisSession fields
- Add buildSessionInfo pure mapper (meta + overview → flat info)
- Add getSessionInfo convenience (one-call DB query)
- Add getSummaryCount and getLastPlatformMessageId helpers
- Electron: thin adapter with cache-first overview, platform-specific dbPath/avatar/aiCount
- Server web.ts & sessions.ts: delegate to getSessionInfo, eliminate inline assembly
- Add 9 unit tests for all new core session functions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-17 13:08:48 +08:00
1 parent 9ad0c49acf
commit f4d5010e77
7 files changed
+360 -227

No files matched your search

+66 -175
View File
@@ -1,6 +1,10 @@
/**
* 会话管理查询模块
* 负责会话列表与单会话基础信息查询
*
* Core session info logic delegated to @openchatlab/core (buildSessionInfo, getSessionMeta, etc.).
* This file retains Electron-specific concerns: filesystem scanning, caching, private chat avatar,
* dbPath, aiConversationCount, and the getChatOverview cache-first pattern.
*/
import Database from 'better-sqlite3'
@@ -16,20 +20,25 @@ import {
type OverviewCache,
type MembersCache,
} from '../../database/sessionCache'
import {
getSessionMeta,
getSessionOverview,
buildSessionInfo,
getSummaryCount,
getLastPlatformMessageId,
type SessionOverview,
} from '@openchatlab/core'
import type { DatabaseAdapter } from '@openchatlab/core'
interface DbMeta {
name: string
platform: string
type: string
imported_at: number
group_id: string | null
group_avatar: string | null
owner_id: string | null
/**
* Wrap a better-sqlite3 Database as a DatabaseAdapter for core query functions.
*/
function asCoreDb(db: Database.Database): DatabaseAdapter {
return db as unknown as DatabaseAdapter
}
/**
* 判断是否为聊天会话数据库
* 通过核心三表(meta/member/message)存在性快速识别
* 判断是否为聊天会话数据库(local fast check, avoids core import overhead for non-session DBs)
*/
function isChatSessionDb(db: Database.Database): boolean {
const requiredTableCount = db
@@ -42,8 +51,11 @@ function isChatSessionDb(db: Database.Database): boolean {
* 获取私聊对方成员的头像
* 逻辑参考 private-chat/index.vue 的 otherMemberAvatar
*/
function getPrivateChatMemberAvatar(db: Database.Database, sessionName: string, ownerId: string | null): string | null {
// 获取所有非系统消息成员(按消息数排序)
function getPrivateChatMemberAvatar(
db: Database.Database,
sessionName: string,
ownerId: string | null | undefined
): string | null {
const members = db
.prepare(
`SELECT
@@ -58,22 +70,34 @@ function getPrivateChatMemberAvatar(db: Database.Database, sessionName: string,
if (members.length === 0) return null
// 1. 优先排除 ownerId,找到另一成员的头像
if (ownerId) {
const other = members.find((m) => m.platformId !== ownerId)
if (other?.avatar) return other.avatar
}
// 2. 尝试匹配会话名称(私聊名称通常是对方昵称)
const sameName = members.find((m) => m.name === sessionName)
if (sameName?.avatar) return sameName.avatar
// 3. 如果只有两个成员且有 ownerId,取另一个(即使没有头像也返回 null)
// 如果没有 ownerId,返回第一个有头像的成员
const firstWithAvatar = members.find((m) => m.avatar)
return firstWithAvatar?.avatar || null
}
/**
* Resolve overview from cache (with compute-on-miss), or fall back to live SQL via core.
*/
function resolveOverview(db: Database.Database, sessionId: string, cacheDir: string | null): SessionOverview {
let cached = cacheDir ? getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, cacheDir) : null
if (!cached && cacheDir) {
try {
cached = computeAndSetOverviewCache(db, sessionId, cacheDir)
} catch {
// cache compute failure — fall through to live query
}
}
if (cached) return cached
return getSessionOverview(asCoreDb(db))
}
/**
* 获取所有会话列表
*/
@@ -94,89 +118,31 @@ export function getAllSessions(): any[] {
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
// 跳过非聊天会话数据库(例如内部索引库)
if (!isChatSessionDb(db)) {
db.close()
continue
}
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
const coreDb = asCoreDb(db)
const meta = getSessionMeta(coreDb)
if (meta) {
// 优先从缓存读取,未命中则实时查询并生成缓存
const cacheDir = getCacheDir()
let overview = getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, cacheDir)
if (!overview && cacheDir) {
try {
overview = computeAndSetOverviewCache(db, sessionId, cacheDir)
} catch {
// 缓存计算失败不影响正常流程
}
}
const overview = resolveOverview(db, sessionId, cacheDir)
const summaryCount = getSummaryCount(coreDb)
const info = buildSessionInfo(meta, overview, summaryCount)
const messageCount =
overview?.totalMessages ??
(
db
.prepare(
`SELECT COUNT(*) as count
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE COALESCE(m.account_name, '') != '系统消息'`
)
.get() as { count: number }
).count
const memberCount =
overview?.totalMembers ??
(
db
.prepare(
`SELECT COUNT(*) as count
FROM member
WHERE COALESCE(account_name, '') != '系统消息'`
)
.get() as { count: number }
).count
// 私聊:获取对方成员头像
let memberAvatar: string | null = null
if (meta.type === 'private') {
memberAvatar = getPrivateChatMemberAvatar(db, meta.name, meta.owner_id)
}
// 摘要数量:安全查询(chat_session 表可能不存在)
let summaryCount = 0
try {
const hasChatSessionTable = db
.prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='chat_session'")
.get() as { cnt: number }
if (hasChatSessionTable.cnt > 0) {
summaryCount = (
db
.prepare("SELECT COUNT(*) as count FROM chat_session WHERE summary IS NOT NULL AND summary != ''")
.get() as { count: number }
).count
}
} catch {
// 忽略查询错误
memberAvatar = getPrivateChatMemberAvatar(db, meta.name, meta.ownerId)
}
sessions.push({
...info,
id: sessionId,
name: meta.name,
platform: meta.platform,
type: meta.type,
importedAt: meta.imported_at,
messageCount,
memberCount,
dbPath,
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
ownerId: meta.owner_id || null,
memberAvatar, // 私聊对方头像
lastMessageTs: overview?.lastMessageTs ?? null,
summaryCount,
aiConversationCount: 0, // 将在 IPC 层由主进程填充
memberAvatar,
aiConversationCount: 0, // filled by IPC layer
})
}
@@ -196,101 +162,47 @@ export function getSession(sessionId: string): any | null {
const db = openDatabase(sessionId)
if (!db) return null
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
const coreDb = asCoreDb(db)
const meta = getSessionMeta(coreDb)
if (!meta) return null
const overview = getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, getCacheDir())
const messageCount =
overview?.totalMessages ??
(
db
.prepare(
`SELECT COUNT(*) as count
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE COALESCE(m.account_name, '') != '系统消息'`
)
.get() as { count: number }
).count
const memberCount =
overview?.totalMembers ??
(
db
.prepare(
`SELECT COUNT(*) as count
FROM member
WHERE COALESCE(account_name, '') != '系统消息'`
)
.get() as { count: number }
).count
// 时间范围(优先从 cache 获取)
const firstTimestamp =
overview?.firstMessageTs ??
(db.prepare('SELECT MIN(ts) as v FROM message').get() as { v: number | null })?.v ??
null
const lastTimestamp =
overview?.lastMessageTs ?? (db.prepare('SELECT MAX(ts) as v FROM message').get() as { v: number | null })?.v ?? null
// 最新的 platform_message_id(用于增量边界参考)
const lastPmidRow = db
.prepare('SELECT platform_message_id FROM message WHERE platform_message_id IS NOT NULL ORDER BY ts DESC LIMIT 1')
.get() as { platform_message_id: string } | undefined
const overview = resolveOverview(db, sessionId, getCacheDir())
const info = buildSessionInfo(meta, overview)
return {
...info,
id: sessionId,
name: meta.name,
platform: meta.platform,
type: meta.type,
importedAt: meta.imported_at,
messageCount,
memberCount,
dbPath: getDbPath(sessionId),
groupId: meta.group_id || null,
groupAvatar: meta.group_avatar || null,
ownerId: meta.owner_id || null,
firstTimestamp,
lastTimestamp,
lastPlatformMessageId: lastPmidRow?.platform_message_id ?? null,
firstTimestamp: overview.firstMessageTs,
lastTimestamp: overview.lastMessageTs,
lastPlatformMessageId: getLastPlatformMessageId(coreDb),
}
}
/**
* 获取聊天概览(AI 工具使用)
* 优先从缓存读取,miss 则计算回填
* Cache-first: overview cache + members cache, fallback to live SQL via core.
*/
export function getChatOverview(sessionId: string, topN: number = 10) {
const db = openDatabase(sessionId)
if (!db) return null
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
const coreDb = asCoreDb(db)
const meta = getSessionMeta(coreDb)
if (!meta) return null
const cacheDir = getCacheDir()
const overview = resolveOverview(db, sessionId, cacheDir)
// 读取 overview 缓存
let overview = getCache<OverviewCache>(sessionId, CACHE_KEY_OVERVIEW, cacheDir)
if (!overview) {
try {
overview = computeAndSetOverviewCache(db, sessionId, cacheDir)
} catch {
// fallback: 实时查询
}
}
// 读取 members 缓存
let membersCache = getCache<MembersCache>(sessionId, CACHE_KEY_MEMBERS, cacheDir)
if (!membersCache) {
try {
membersCache = computeAndSetMembersCache(db, sessionId, cacheDir)
} catch {
// fallback: 无成员数据
// fallback: no member data
}
}
// 从缓存计算 Top N 活跃成员
let topMembers: Array<{ id: number; name: string; count: number }> = []
if (membersCache?.members) {
topMembers = Object.entries(membersCache.members)
@@ -299,35 +211,14 @@ export function getChatOverview(sessionId: string, topN: number = 10) {
.slice(0, topN)
}
// fallback 统计值
const totalMessages =
overview?.totalMessages ??
(
db
.prepare(
`SELECT COUNT(*) as count FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE COALESCE(m.account_name, '') != '系统消息'`
)
.get() as { count: number }
).count
const totalMembers =
overview?.totalMembers ??
(
db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as {
count: number
}
).count
return {
name: meta.name,
platform: meta.platform,
type: meta.type,
totalMessages,
totalMembers,
firstMessageTs: overview?.firstMessageTs ?? null,
lastMessageTs: overview?.lastMessageTs ?? null,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
topMembers,
}
}
+5
View File
@@ -29,6 +29,10 @@ export {
searchSessions,
getSessionMessages,
getSessionSummaries,
buildSessionInfo,
getSessionInfo,
getSummaryCount,
getLastPlatformMessageId,
getTimeRange,
getAvailableYears,
getMemberActivity,
@@ -81,6 +85,7 @@ export type {
SessionMeta,
SessionOverview,
SessionInfo,
CoreSessionInfo,
ChatOverviewData,
SessionSearchItem,
SessionMessagesData,
@@ -0,0 +1,175 @@
/**
* Minimal tests for session query functions.
*
* Verifies buildSessionInfo pure mapper and getSessionInfo / getSummaryCount / getLastPlatformMessageId
* against a mock DatabaseAdapter.
*
* Run: npx tsx --test packages/core/src/query/__tests__/session-queries.test.ts
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { SessionMeta, SessionOverview } from '../session-queries'
import { buildSessionInfo, getSessionInfo, getSummaryCount, getLastPlatformMessageId } from '../session-queries'
import type { DatabaseAdapter } from '../../interfaces'
// ==================== Mock helpers ====================
function makeMeta(overrides?: Partial<SessionMeta>): SessionMeta {
return {
name: 'Test Group',
platform: 'wechat',
type: 'group',
importedAt: 1700000000,
groupId: 'g001',
groupAvatar: null,
ownerId: 'u001',
...overrides,
}
}
function makeOverview(overrides?: Partial<SessionOverview>): SessionOverview {
return {
totalMessages: 500,
totalMembers: 10,
firstMessageTs: 1600000000,
lastMessageTs: 1700000000,
...overrides,
}
}
type QueryResult = Record<string, unknown>
/**
* Create a mock DatabaseAdapter whose prepare().get()/all() respond based on SQL patterns.
*/
function createMockDb(handlers: Record<string, (args: unknown[]) => QueryResult | QueryResult[]>): DatabaseAdapter {
return {
prepare(sql: string) {
return {
get(...args: unknown[]) {
for (const [pattern, handler] of Object.entries(handlers)) {
if (sql.includes(pattern)) {
return handler(args)
}
}
return undefined
},
all(...args: unknown[]) {
for (const [pattern, handler] of Object.entries(handlers)) {
if (sql.includes(pattern)) {
const result = handler(args)
return Array.isArray(result) ? result : [result]
}
}
return []
},
run() {
return { changes: 0, lastInsertRowid: 0 }
},
}
},
} as unknown as DatabaseAdapter
}
// ==================== Tests ====================
describe('buildSessionInfo', () => {
it('composes meta + overview into flat CoreSessionInfo', () => {
const meta = makeMeta()
const overview = makeOverview()
const info = buildSessionInfo(meta, overview, 3)
assert.equal(info.name, 'Test Group')
assert.equal(info.platform, 'wechat')
assert.equal(info.type, 'group')
assert.equal(info.importedAt, 1700000000)
assert.equal(info.messageCount, 500)
assert.equal(info.memberCount, 10)
assert.equal(info.firstMessageTs, 1600000000)
assert.equal(info.lastMessageTs, 1700000000)
assert.equal(info.groupId, 'g001')
assert.equal(info.groupAvatar, null)
assert.equal(info.ownerId, 'u001')
assert.equal(info.summaryCount, 3)
})
it('defaults summaryCount to 0 when omitted', () => {
const info = buildSessionInfo(makeMeta(), makeOverview())
assert.equal(info.summaryCount, 0)
})
it('preserves null timestamps when overview has no messages', () => {
const overview = makeOverview({ firstMessageTs: null, lastMessageTs: null })
const info = buildSessionInfo(makeMeta(), overview)
assert.equal(info.firstMessageTs, null)
assert.equal(info.lastMessageTs, null)
})
})
describe('getSessionInfo', () => {
it('returns null when meta table is empty', () => {
const db = createMockDb({
'FROM meta': () => undefined as unknown as QueryResult,
})
assert.equal(getSessionInfo(db), null)
})
it('returns combined CoreSessionInfo from DB', () => {
const db = createMockDb({
'FROM meta': () => ({
name: 'Chat',
platform: 'telegram',
type: 'private',
imported_at: 1700000000,
group_id: null,
group_avatar: null,
owner_id: 'me',
}),
'COUNT(*)': () => ({ count: 42 }),
'MIN(ts)': () => ({ v: 1600000000 }),
'MAX(ts)': () => ({ v: 1700000000 }),
sqlite_master: () => ({ cnt: 0 }),
})
const info = getSessionInfo(db)
assert.ok(info)
assert.equal(info.name, 'Chat')
assert.equal(info.platform, 'telegram')
assert.equal(info.type, 'private')
assert.equal(info.ownerId, 'me')
})
})
describe('getSummaryCount', () => {
it('returns 0 when chat_session table does not exist', () => {
const db = createMockDb({
sqlite_master: () => ({ cnt: 0 }),
})
assert.equal(getSummaryCount(db), 0)
})
it('returns count when chat_session table exists', () => {
const db = createMockDb({
sqlite_master: () => ({ cnt: 1 }),
'FROM chat_session': () => ({ count: 7 }),
})
assert.equal(getSummaryCount(db), 7)
})
})
describe('getLastPlatformMessageId', () => {
it('returns null when no platform_message_id exists', () => {
const db = createMockDb({
platform_message_id: () => undefined as unknown as QueryResult,
})
assert.equal(getLastPlatformMessageId(db), null)
})
it('returns the latest platform_message_id', () => {
const db = createMockDb({
platform_message_id: () => ({ platform_message_id: 'pmid-999' }),
})
assert.equal(getLastPlatformMessageId(db), 'pmid-999')
})
})
+5
View File
@@ -9,11 +9,16 @@ export {
searchSessions,
getSessionMessages,
getSessionSummaries,
buildSessionInfo,
getSessionInfo,
getSummaryCount,
getLastPlatformMessageId,
} from './session-queries'
export type {
SessionMeta,
SessionOverview,
SessionInfo,
CoreSessionInfo,
ChatOverviewData,
SessionSearchItem,
SessionMessagesData,
@@ -32,6 +32,87 @@ export interface SessionInfo extends SessionMeta {
overview: SessionOverview
}
// ==================== Flat session info (for AnalysisSession-like consumers) ====================
/**
* Platform-agnostic session info built from meta + overview.
* Covers all AnalysisSession fields that are queryable from a single DB.
* Platform-specific fields (dbPath, memberAvatar, aiConversationCount) are left to callers.
*/
export interface CoreSessionInfo {
name: string
platform: string
type: string
importedAt: number
messageCount: number
memberCount: number
groupId: string | null
groupAvatar: string | null
ownerId: string | null
firstMessageTs: number | null
lastMessageTs: number | null
summaryCount: number
}
/**
* Pure mapper: compose SessionMeta + SessionOverview into flat CoreSessionInfo.
* Callers provide the inputs which may come from cache or fresh SQL.
*/
export function buildSessionInfo(
meta: SessionMeta,
overview: SessionOverview,
summaryCount: number = 0
): CoreSessionInfo {
return {
name: meta.name,
platform: meta.platform,
type: meta.type,
importedAt: meta.importedAt,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
groupId: meta.groupId,
groupAvatar: meta.groupAvatar,
ownerId: meta.ownerId,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
summaryCount,
}
}
/**
* Convenience: read meta + overview from DB and return flat CoreSessionInfo.
*/
export function getSessionInfo(db: DatabaseAdapter): CoreSessionInfo | null {
const meta = getSessionMeta(db)
if (!meta) return null
const overview = getSessionOverview(db)
const sc = getSummaryCount(db)
return buildSessionInfo(meta, overview, sc)
}
/**
* Count of chat sessions that have an AI-generated summary.
*/
export function getSummaryCount(db: DatabaseAdapter): number {
if (!hasTable(db, 'chat_session')) return 0
const row = db
.prepare("SELECT COUNT(*) as count FROM chat_session WHERE summary IS NOT NULL AND summary != ''")
.get() as { count: number } | undefined
return row?.count ?? 0
}
/**
* Get the latest platform_message_id (used as incremental import boundary).
*/
export function getLastPlatformMessageId(db: DatabaseAdapter): string | null {
const row = db
.prepare('SELECT platform_message_id FROM message WHERE platform_message_id IS NOT NULL ORDER BY ts DESC LIMIT 1')
.get() as { platform_message_id: string } | undefined
return row?.platform_message_id ?? null
}
// ==================== Core identification ====================
/**
* 判断数据库是否为聊天会话数据库
* 通过核心三表(meta/member/message)存在性快速识别
+21 -22
View File
@@ -8,6 +8,7 @@
import type { FastifyInstance } from 'fastify'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import {
getSessionInfo,
getSessionMeta,
getSessionOverview,
queryMessages,
@@ -35,19 +36,18 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa
.map((id) => {
const db = dbManager.open(id)
if (!db) return null
const meta = getSessionMeta(db)
if (!meta) return null
const overview = getSessionOverview(db)
const info = getSessionInfo(db)
if (!info) return null
return {
id,
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
firstTimestamp: overview.firstMessageTs,
lastTimestamp: overview.lastMessageTs,
name: info.name,
platform: info.platform,
type: info.type,
groupId: info.groupId || undefined,
messageCount: info.messageCount,
memberCount: info.memberCount,
firstTimestamp: info.firstMessageTs,
lastTimestamp: info.lastMessageTs,
}
})
.filter(Boolean)
@@ -57,19 +57,18 @@ export function registerSessionRoutes(server: FastifyInstance, dbManager: Databa
// GET /api/v1/sessions/:id — Single session detail
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => {
const db = ensureDb(dbManager, request.params.id)
const meta = getSessionMeta(db)
if (!meta) throw sessionNotFound(request.params.id)
const overview = getSessionOverview(db)
const info = getSessionInfo(db)
if (!info) throw sessionNotFound(request.params.id)
return successResponse({
id: request.params.id,
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
firstTimestamp: overview.firstMessageTs,
lastTimestamp: overview.lastMessageTs,
name: info.name,
platform: info.platform,
type: info.type,
groupId: info.groupId || undefined,
messageCount: info.messageCount,
memberCount: info.memberCount,
firstTimestamp: info.firstMessageTs,
lastTimestamp: info.lastMessageTs,
})
})
+7 -30
View File
@@ -12,8 +12,7 @@ import type { FastifyInstance } from 'fastify'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import type { TimeFilter } from '@openchatlab/shared-types'
import {
getSessionMeta,
getSessionOverview,
getSessionInfo,
getTimeRange,
getAvailableYears,
getMemberActivity,
@@ -80,24 +79,13 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa
.map((id) => {
const db = dbManager.open(id)
if (!db) return null
const meta = getSessionMeta(db)
if (!meta) return null
const overview = getSessionOverview(db)
const info = getSessionInfo(db)
if (!info) return null
return {
...info,
id,
name: meta.name,
platform: meta.platform,
type: meta.type,
importedAt: meta.importedAt ?? 0,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
dbPath: '',
groupId: meta.groupId ?? null,
groupAvatar: meta.groupAvatar ?? null,
ownerId: meta.ownerId ?? null,
memberAvatar: null,
lastMessageTs: overview.lastMessageTs ?? null,
summaryCount: 0,
aiConversationCount: 0,
}
})
@@ -106,24 +94,13 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa
server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => {
const db = ensureDb(dbManager, request.params.id)
const meta = getSessionMeta(db)
if (!meta) return null
const overview = getSessionOverview(db)
const info = getSessionInfo(db)
if (!info) return null
return {
...info,
id: request.params.id,
name: meta.name,
platform: meta.platform,
type: meta.type,
importedAt: meta.importedAt ?? 0,
messageCount: overview.totalMessages,
memberCount: overview.totalMembers,
dbPath: '',
groupId: meta.groupId ?? null,
groupAvatar: meta.groupAvatar ?? null,
ownerId: meta.ownerId ?? null,
memberAvatar: null,
lastMessageTs: overview.lastMessageTs ?? null,
summaryCount: 0,
aiConversationCount: 0,
}
})