feat(contacts): simplify contacts experience

This commit is contained in:
digua committed 2026-06-27 15:54:08 +08:00
1 parent 11152a7c4b
commit d48665b2ed
24 files changed
+744 -1198

No files matched your search

-5
View File
@@ -365,7 +365,6 @@ export * as summaryService from './services/summary-service'
export * as exportService from './services/export-service'
export * as ownerProfileService from './services/owner-profile-service'
export * as contactsService from './services/contacts-service'
export * as contactsOverrides from './services/contacts-overrides'
// Semantic index (Phase 1 vector search) — independent of legacy ai/rag
export * as semanticIndex from './semantic-index'
export {
@@ -395,9 +394,7 @@ export type {
export {
CONTACTS_ALGORITHM_VERSION,
ContactsOverridesManager,
MergeSessionCache,
buildContactOverrideKey,
createContactsService,
createDatabaseManagerAdapter,
pushImport,
@@ -412,8 +409,6 @@ export type {
ApplyOwnerProfileReason,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
BuildContactOverrideKeyInput,
ContactsOverrides,
ContactsService,
ContactsServiceDeps,
ContactsServiceOptions,
@@ -1,103 +0,0 @@
/**
* Tests for contacts override persistence.
*
* Run: pnpm test -- packages/node-runtime/src/services/contacts-overrides.test.ts
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { initAppLogger } from '../logging/app-logger'
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-overrides-'))
}
function readJson(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
test('loads empty overrides when contacts-overrides.json is missing', (t) => {
const dir = makeTempDir()
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
const manager = new ContactsOverridesManager(dir)
assert.equal(manager.getFilePath(), path.join(dir, 'contacts-overrides.json'))
assert.deepEqual(manager.load(), {})
assert.equal(manager.getSignaturePart(), '-')
})
test('saveOverride writes valid JSON and deleteOverride removes entries', (t) => {
const dir = makeTempDir()
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
const manager = new ContactsOverridesManager(dir)
const saved = manager.saveOverride('weixin:wxid_a', { lockedTier: 'core' })
assert.equal(saved.lockedTier, 'core')
assert.equal(typeof saved.updatedAt, 'number')
assert.deepEqual(readJson(manager.getFilePath()), {
'weixin:wxid_a': saved,
})
manager.deleteOverride('weixin:wxid_a')
assert.deepEqual(readJson(manager.getFilePath()), {})
})
test('buildContactOverrideKey uses stable ids and session scope for name-match platforms', () => {
assert.equal(buildContactOverrideKey({ platform: 'weixin', platformId: 'wxid_a' }), 'weixin:wxid_a')
assert.equal(
buildContactOverrideKey({ platform: 'whatsapp', platformId: 'Alice', sessionId: 'session-1', matchMode: 'name' }),
'whatsapp:session-1:Alice'
)
assert.equal(
buildContactOverrideKey({ platform: 'line', platformId: 'Bob', sessionId: 'line-session' }),
'line:line-session:Bob'
)
assert.throws(() => buildContactOverrideKey({ platform: 'instagram', platformId: 'Carol' }), /sessionId is required/)
})
test('corrupt override file is backed up and loads empty overrides', (t) => {
const dir = makeTempDir()
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
initAppLogger(path.join(dir, 'logs'))
const filePath = path.join(dir, 'contacts-overrides.json')
fs.writeFileSync(filePath, '{ broken', 'utf-8')
const manager = new ContactsOverridesManager(dir)
assert.deepEqual(manager.load(), {})
const backupsDir = path.join(dir, 'backups')
const backups = fs.readdirSync(backupsDir).filter((name) => name.startsWith('contacts-overrides-corrupt-'))
assert.equal(backups.length, 1)
assert.equal(fs.readFileSync(path.join(backupsDir, backups[0]), 'utf-8'), '{ broken')
const log = fs.readFileSync(path.join(dir, 'logs', 'app.log'), 'utf-8')
assert.match(log, /contacts overrides file is corrupt/)
})
test('atomic write leaves complete JSON and no temp file behind', (t) => {
const dir = makeTempDir()
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
const manager = new ContactsOverridesManager(dir)
manager.saveOverride('qq:10086', { lockedTier: 'core' })
manager.saveOverride('qq:10010', { lockedTier: 'friend' })
const raw = fs.readFileSync(manager.getFilePath(), 'utf-8')
assert.doesNotThrow(() => JSON.parse(raw))
assert.deepEqual(Object.keys(readJson(manager.getFilePath()) as Record<string, unknown>).sort(), [
'qq:10010',
'qq:10086',
])
assert.deepEqual(
fs.readdirSync(dir).filter((name) => name.includes('contacts-overrides') && name.endsWith('.tmp')),
[]
)
assert.notEqual(manager.getSignaturePart(), '-')
})
@@ -1,158 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { isNameMatchPlatform } from '@openchatlab/core'
import type {
ChatPlatform,
ContactOverride,
ContactOverridePatch,
ContactTier,
OwnerMatchMode,
} from '@openchatlab/shared-types'
import { appLogger } from '../logging/app-logger'
export type ContactsOverrides = Record<string, ContactOverride>
export interface BuildContactOverrideKeyInput {
platform: ChatPlatform
platformId: string
sessionId?: string
matchMode?: OwnerMatchMode
}
const OVERRIDES_FILE = 'contacts-overrides.json'
const VALID_CONTACT_TIERS: ReadonlySet<ContactTier> = new Set([
'core',
'friend',
'acquaintance',
'high_interaction',
'medium_interaction',
'low_interaction',
])
export function buildContactOverrideKey(input: BuildContactOverrideKeyInput): string {
const platform = input.platform.trim()
const platformId = input.platformId.trim()
if (!platform) throw new Error('platform is required')
if (!platformId) throw new Error('platformId is required')
const sessionScoped = input.matchMode === 'name' || isNameMatchPlatform(platform)
if (!sessionScoped) return `${platform}:${platformId}`
const sessionId = input.sessionId?.trim()
if (!sessionId) throw new Error('sessionId is required for session-scoped contact override keys')
return `${platform}:${sessionId}:${platformId}`
}
export class ContactsOverridesManager {
private readonly filePath: string
constructor(systemDir: string) {
this.filePath = path.join(systemDir, OVERRIDES_FILE)
}
getFilePath(): string {
return this.filePath
}
load(): ContactsOverrides {
if (!fs.existsSync(this.filePath)) return {}
let raw = ''
try {
raw = fs.readFileSync(this.filePath, 'utf-8')
return normalizeOverrides(JSON.parse(raw))
} catch (error) {
this.backupCorruptFile(raw)
appLogger.warn('contacts', 'contacts overrides file is corrupt', error)
return {}
}
}
saveOverride(key: string, patch: ContactOverridePatch): ContactOverride {
const overrides = this.load()
const current = overrides[key] ?? {}
const next = normalizeOverride({
...current,
...patch,
updatedAt: Date.now(),
})
overrides[key] = next
this.writeOverrides(overrides)
return next
}
deleteOverride(key: string): void {
const overrides = this.load()
delete overrides[key]
this.writeOverrides(overrides)
}
getSignaturePart(): string {
try {
const stat = fs.statSync(this.filePath)
return `${Math.floor(stat.mtimeMs)}:${stat.size}`
} catch {
return '-'
}
}
private writeOverrides(overrides: ContactsOverrides): void {
const dir = path.dirname(this.filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
try {
fs.writeFileSync(tempPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf-8')
fs.renameSync(tempPath, this.filePath)
} catch (error) {
try {
fs.rmSync(tempPath, { force: true })
} catch {
// ignore cleanup failures; the original write error is the actionable one
}
throw error
}
}
private backupCorruptFile(raw: string): void {
try {
const backupDir = path.join(path.dirname(this.filePath), 'backups')
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true })
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(backupDir, `contacts-overrides-corrupt-${timestamp}.json`)
fs.writeFileSync(backupPath, raw, 'utf-8')
} catch (error) {
appLogger.warn('contacts', 'failed to back up corrupt contacts overrides file', error)
}
}
}
function normalizeOverrides(value: unknown): ContactsOverrides {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const result: ContactsOverrides = {}
for (const [key, override] of Object.entries(value as Record<string, unknown>)) {
if (!key.trim()) continue
if (!override || typeof override !== 'object' || Array.isArray(override)) continue
result[key] = normalizeOverride(override as ContactOverride)
}
return result
}
function normalizeOverride(override: ContactOverride): ContactOverride {
const normalized: ContactOverride = {}
if (override.lockedTier === null || isValidContactTier(override.lockedTier)) {
normalized.lockedTier = override.lockedTier
}
if (typeof override.updatedAt === 'number' && Number.isFinite(override.updatedAt)) {
normalized.updatedAt = override.updatedAt
}
return normalized
}
function isValidContactTier(value: unknown): value is ContactTier {
return typeof value === 'string' && VALID_CONTACT_TIERS.has(value as ContactTier)
}
@@ -192,7 +192,7 @@ test('aggregates stable-id contacts across private and group sessions', (t) => {
],
})
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
const result = createContactsService({ adapter: env.adapter }).getContacts()
const byKey = new Map(result.contacts.map((contact) => [contact.key, contact]))
const alice = byKey.get('weixin:alice')
const bob = byKey.get('weixin:bob')
@@ -247,7 +247,7 @@ test('records diagnostics for missing owner, unresolved owner, and ambiguous pri
],
})
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
const result = createContactsService({ adapter: env.adapter }).getContacts()
assert.equal(result.contacts.length, 0)
assert.equal(result.diagnostics.privateSessionCount, 3)
@@ -256,7 +256,7 @@ test('records diagnostics for missing owner, unresolved owner, and ambiguous pri
assert.equal(result.diagnostics.skippedAmbiguousPrivateSessions, 1)
})
test('keeps name-match platform contacts session-scoped and applies manual overrides only to existing contacts', (t) => {
test('keeps name-match platform contacts session-scoped', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
@@ -274,20 +274,53 @@ test('keeps name-match platform contacts session-scoped and applies manual overr
})
}
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir })
service.setContactOverride('whatsapp:whatsapp-a:Alice', { lockedTier: 'core' })
service.setContactOverride('whatsapp:ghost:Alice', { lockedTier: 'core' })
const service = createContactsService({ adapter: env.adapter })
const result = service.getContacts()
const keys = result.contacts.map((contact) => contact.key).sort()
assert.deepEqual(keys, ['whatsapp:whatsapp-a:Alice', 'whatsapp:whatsapp-b:Alice'])
const locked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-a:Alice')
const unlocked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-b:Alice')
assert.ok(locked)
assert.equal(locked.tier, 'core')
assert.equal(locked.lockedTier, 'core')
assert.ok(unlocked)
assert.equal(unlocked.lockedTier, null)
})
test('sorts contacts by score and marks low-signal non-friends', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({
id: 'private-a',
platform: 'weixin',
type: 'private',
ownerId: 'owner',
members: [
{ id: 1, platformId: 'owner' },
{ id: 2, platformId: 'alice' },
],
messages: privateMessages(60, 1, 1704103200),
})
env.seed({
id: 'group-a',
platform: 'weixin',
type: 'group',
ownerId: 'owner',
members: [
{ id: 1, platformId: 'owner' },
{ id: 2, platformId: 'alice' },
{ id: 3, platformId: 'bob' },
],
messages: [
{ id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' },
{ id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'bob-1' },
],
})
const service = createContactsService({ adapter: env.adapter })
const result = service.getContacts()
assert.deepEqual(
result.contacts.map((contact) => contact.key),
['weixin:alice', 'weixin:bob']
)
assert.equal(result.contacts[1].isLowSignal, true)
assert.equal(result.diagnostics.hiddenLowSignalNonFriends, 1)
})
test('returns stale cached contacts when signature changes and acceptStale is true', (t) => {
@@ -307,7 +340,7 @@ test('returns stale cached contacts when signature changes and acceptStale is tr
messages: privateMessages(5, 1, 1704103200),
})
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
const service = createContactsService({ adapter: env.adapter, now: () => now })
const first = service.getContacts()
assert.equal(first.cache.status, 'fresh')
assert.equal(first.cache.computedAt, 1000)
@@ -345,7 +378,7 @@ test('recomputes contacts after owner_id changes in a session database', (t) =>
messages: privateMessages(5, 1, 1704103200),
})
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
const service = createContactsService({ adapter: env.adapter, now: () => now })
const first = service.getContacts()
assert.deepEqual(
first.contacts.map((contact) => contact.key),
@@ -2,24 +2,20 @@ import { ChatType } from '@openchatlab/shared-types'
import type {
ChatPlatform,
ContactItem,
ContactOverridePatch,
ContactsCacheState,
ContactsDiagnostics,
ContactsResponse,
ContactSourceSession,
ContactTier,
} from '@openchatlab/shared-types'
import {
MIN_PRIVATE_SESSIONS_FOR_CONTACTS,
applyContactOverride,
assignFriendTiers,
assignNonFriendTiers,
computeFriendScores,
computeNonFriendScores,
getGroupContactFacts,
getPrivateContactFacts,
getSessionMeta,
isChatSessionDb,
isLowSignalNonFriend,
isNameMatchPlatform,
resolveOwnerMember,
} from '@openchatlab/core'
@@ -27,7 +23,6 @@ import type { ContactMemberRef, SessionMeta } from '@openchatlab/core'
import { getDbFileVersion } from '../cache/analytics-cache'
import { appLogger } from '../logging/app-logger'
import type { SessionRuntimeAdapter } from './adapters'
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
export const CONTACTS_ALGORITHM_VERSION = 'contacts-v1'
@@ -38,14 +33,11 @@ export interface ContactsServiceOptions {
export interface ContactsServiceDeps {
adapter: SessionRuntimeAdapter
systemDir: string
now?: () => number
}
export interface ContactsService {
getContacts(options?: ContactsServiceOptions): ContactsResponse
setContactOverride(key: string, patch: ContactOverridePatch): void
deleteContactOverride(key: string): void
invalidateContactsCache(): void
}
@@ -89,12 +81,9 @@ export function createContactsService(deps: ContactsServiceDeps): ContactsServic
}
class DefaultContactsService implements ContactsService {
private readonly overrides: ContactsOverridesManager
private cache: CachedContacts | null = null
constructor(private readonly deps: ContactsServiceDeps) {
this.overrides = new ContactsOverridesManager(deps.systemDir)
}
constructor(private readonly deps: ContactsServiceDeps) {}
getContacts(options: ContactsServiceOptions = {}): ContactsResponse {
const signature = this.buildSignature()
@@ -119,18 +108,6 @@ class DefaultContactsService implements ContactsService {
return this.toResponse(this.cache, 'fresh')
}
setContactOverride(key: string, patch: ContactOverridePatch): void {
this.overrides.saveOverride(key, patch)
this.invalidateContactsCache()
appLogger.info('contacts', 'contact override saved', { key })
}
deleteContactOverride(key: string): void {
this.overrides.deleteOverride(key)
this.invalidateContactsCache()
appLogger.info('contacts', 'contact override deleted', { key })
}
invalidateContactsCache(): void {
this.cache = null
}
@@ -239,7 +216,6 @@ class DefaultContactsService implements ContactsService {
}
private buildContactItems(accumulators: ContactAccumulator[], diagnostics: ContactsDiagnostics): ContactItem[] {
const overrides = this.overrides.load()
const friendInputs = accumulators
.filter((acc) => acc.isFriend)
.map((acc) => ({
@@ -259,38 +235,21 @@ class DefaultContactsService implements ContactsService {
}))
const friendScores = computeFriendScores(friendInputs)
const friendTierInputs = friendInputs.map((input) => ({
input,
score: friendScores.get(input)?.score ?? 0,
privateMessageCount: input.privateMessageCount,
}))
const friendTiers = assignFriendTiers(friendTierInputs)
const nonFriendScores = computeNonFriendScores(nonFriendInputs)
const nonFriendTierInputs = nonFriendInputs.map((input) => ({
input,
score: nonFriendScores.get(input)?.score ?? 0,
coOccurrenceCount: input.coOccurrenceCount,
replyInteractionCount: input.replyInteractionCount,
}))
const nonFriendTiers = assignNonFriendTiers(nonFriendTierInputs)
const contacts: ContactItem[] = []
// 评分和分层按好友/非好友分池计算;最终再应用手动锁定,保证用户选择拥有最高优先级。
for (const tierInput of friendTierInputs) {
const { input } = tierInput
// 评分仍按好友/群聊联系人分池计算;分池后的 score 只用于排序,不再生成关系定性标签。
for (const input of friendInputs) {
const score = friendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
const algorithmTier = friendTiers.tiers.get(tierInput) ?? 'acquaintance'
contacts.push(this.toContactItem(input.acc, 'friend', algorithmTier, score, overrides[input.acc.key]))
contacts.push(this.toContactItem(input.acc, 'friend', false, score))
}
for (const tierInput of nonFriendTierInputs) {
const { input } = tierInput
for (const input of nonFriendInputs) {
const score = nonFriendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
const algorithmTier = nonFriendTiers.tiers.get(tierInput) ?? 'low_interaction'
const item = this.toContactItem(input.acc, 'non_friend', algorithmTier, score, overrides[input.acc.key])
if (item.tier === 'low_interaction') diagnostics.hiddenLowInteractionNonFriends++
const isLowSignal = isLowSignalNonFriend(input)
const item = this.toContactItem(input.acc, 'non_friend', isLowSignal, score)
if (item.isLowSignal) diagnostics.hiddenLowSignalNonFriends++
contacts.push(item)
}
@@ -300,11 +259,9 @@ class DefaultContactsService implements ContactsService {
private toContactItem(
acc: ContactAccumulator,
pool: 'friend' | 'non_friend',
algorithmTier: ContactTier,
scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] },
override: Parameters<typeof applyContactOverride>[1]
isLowSignal: boolean,
scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] }
): ContactItem {
const applied = applyContactOverride(algorithmTier, override)
const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName)
const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase()
@@ -319,9 +276,7 @@ class DefaultContactsService implements ContactsService {
avatar: acc.avatar,
isFriend: acc.isFriend,
pool,
tier: applied.tier,
algorithmTier: applied.algorithmTier,
lockedTier: applied.lockedTier,
isLowSignal,
score: scoring.score,
scoreBreakdown: {
...scoring.scoreBreakdown,
@@ -341,7 +296,7 @@ class DefaultContactsService implements ContactsService {
}
private buildSignature(): string {
const parts = [`algorithm:${CONTACTS_ALGORITHM_VERSION}`, `overrides:${this.overrides.getSignaturePart()}`]
const parts = [`algorithm:${CONTACTS_ALGORITHM_VERSION}`]
for (const sessionId of [...this.deps.adapter.listSessionIds()].sort()) {
const dbPath = this.deps.adapter.getDbPath(sessionId)
parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`)
@@ -381,7 +336,7 @@ function createEmptyDiagnostics(): ContactsDiagnostics {
skippedAmbiguousPrivateSessions: 0,
skippedInvalidPlatformIdMembers: 0,
skippedFailedSessions: 0,
hiddenLowInteractionNonFriends: 0,
hiddenLowSignalNonFriends: 0,
warnings: [],
}
}
@@ -393,12 +348,7 @@ function getOrCreateAccumulator(
contact: ContactMemberRef
): ContactAccumulator {
const sessionScoped = isNameMatchPlatform(meta.platform)
const key = buildContactOverrideKey({
platform: meta.platform,
platformId: contact.platformId,
sessionId,
matchMode: sessionScoped ? 'name' : 'platform_id',
})
const key = buildContactKey(meta.platform, contact.platformId, sessionScoped ? sessionId : undefined)
const existing = accumulators.get(key)
if (existing) {
mergeContactIdentity(existing, contact)
@@ -430,6 +380,16 @@ function getOrCreateAccumulator(
return created
}
function buildContactKey(platform: ChatPlatform, platformId: string, sessionId?: string): string {
const normalizedPlatform = platform.trim()
const normalizedPlatformId = platformId.trim()
if (!normalizedPlatform) throw new Error('platform is required')
if (!normalizedPlatformId) throw new Error('platformId is required')
return sessionId?.trim()
? `${normalizedPlatform}:${sessionId.trim()}:${normalizedPlatformId}`
: `${normalizedPlatform}:${normalizedPlatformId}`
}
function mergeContactIdentity(acc: ContactAccumulator, contact: ContactMemberRef): void {
if (contact.name) acc.aliases.add(contact.name)
acc.aliases.add(contact.platformId)
@@ -55,10 +55,6 @@ export type { LlmConfig, SummaryServiceDeps } from './summary-service'
// Export service
export { exportMarkdown } from './export-service'
// Contacts overrides
export { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
export type { BuildContactOverrideKeyInput, ContactsOverrides } from './contacts-overrides'
// Contacts service
export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts-service'
export type { ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts-service'