fix(relationships): address galaxy review regressions

Bundle relationship worker entries for packaged builds.

Refresh and surface galaxy task failures, clean detached Pixi layers, cap focused neighborhoods, merge owner nodes across platforms, apply manual friend overrides, and track owner group activity.
This commit is contained in:
digua committed 2026-06-30 14:41:46 +08:00
1 parent 9c2ab48b1c
commit d3d2661882
14 files changed
+707 -28

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
index: 'src/index.ts',
'semantic-index-worker': '../../packages/node-runtime/src/semantic-index/worker-thread-entry.ts',
'contacts-worker': '../../packages/node-runtime/src/services/contacts/worker-entry.ts',
'people-relationships-worker': '../../packages/node-runtime/src/services/people/relationships/worker-entry.ts',
},
format: ['esm'],
outDir: 'dist',
+4
View File
@@ -30,6 +30,10 @@ export default defineConfig({
'worker/dbWorker': resolve(__dirname, 'main/worker/dbWorker.ts'),
'semantic-index-worker': resolve(rootDir, 'packages/node-runtime/src/semantic-index/worker-thread-entry.ts'),
'contacts-worker': resolve(rootDir, 'packages/node-runtime/src/services/contacts/worker-entry.ts'),
'people-relationships-worker': resolve(
rootDir,
'packages/node-runtime/src/services/people/relationships/worker-entry.ts'
),
},
},
},
@@ -277,6 +277,38 @@ test('computes a cropped relationship galaxy from private contacts and group int
assert.equal(snapshot.diagnostics.processedPrivateSessions, 1)
})
test('counts owner group messages on the owner relationship node', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({
id: 'group-owner-messages',
platform: 'weixin',
type: 'group',
ownerId: 'owner',
members: [
{ id: 1, platformId: 'owner', accountName: 'Me' },
{ id: 2, platformId: 'alice', accountName: 'Alice' },
],
messages: [
{ id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' },
{ id: 2, senderId: 1, ts: 1704103201, platformMessageId: 'owner-2' },
{ id: 3, senderId: 1, ts: 1704103202, platformMessageId: 'owner-3' },
{ id: 4, senderId: 2, ts: 1704103203, platformMessageId: 'alice-1' },
],
})
const snapshot = computePeopleRelationshipsSnapshot({
adapter: env.adapter,
signature: 'sig-owner-group-count',
timeRangePreset: 'all',
})
const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner')
assert.ok(owner)
assert.equal(owner.groupMessageCount, 3)
})
test('prefers recent owner edges when cropping dense relationship graph edges', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
@@ -598,6 +630,68 @@ test('lays out the panorama around owner and pushes large noisy groups outward',
assert.ok(distanceOf(smallPeer) < distanceOf(largePeer))
})
test('merges owner nodes across platforms into one relationship graph center', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({
id: 'weixin-private',
platform: 'weixin',
type: 'private',
ownerId: 'owner',
members: [
{ id: 1, platformId: 'owner', accountName: 'Me' },
{ id: 2, platformId: 'wx-friend', accountName: 'Weixin Friend' },
],
messages: [
{ id: 1, senderId: 1, ts: 1704103200 },
{ id: 2, senderId: 2, ts: 1704103201 },
],
})
env.seed({
id: 'telegram-private',
platform: 'telegram',
type: 'private',
ownerId: 'owner',
members: [
{ id: 1, platformId: 'owner', accountName: 'Me' },
{ id: 2, platformId: 'tg-friend', accountName: 'Telegram Friend' },
],
messages: [
{ id: 1, senderId: 1, ts: 1704103300 },
{ id: 2, senderId: 2, ts: 1704103301 },
],
})
const snapshot = computePeopleRelationshipsSnapshot({
adapter: env.adapter,
signature: 'sig-1',
timeRangePreset: 'all',
limits: {
coreNodeLimit: 10,
coreEdgeLimit: 10,
perNodeEdgeLimit: 10,
},
})
const owners = snapshot.nodes.filter((node) => node.kind === 'owner')
assert.equal(owners.length, 1)
assert.equal(owners[0]?.key, 'owner')
assert.equal(owners[0]?.x, 0)
assert.equal(owners[0]?.y, 0)
const ownerEdges = snapshot.graph.edges.filter((edge) => edge.sourceKey === 'owner' || edge.targetKey === 'owner')
assert.equal(ownerEdges.length, 2)
assert.equal(
ownerEdges.some((edge) => edge.sourceKey === 'weixin:wx-friend' || edge.targetKey === 'weixin:wx-friend'),
true
)
assert.equal(
ownerEdges.some((edge) => edge.sourceKey === 'telegram:tg-friend' || edge.targetKey === 'telegram:tg-friend'),
true
)
})
test('excludes no-friend groups from the default panorama while keeping full relationship data', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
@@ -752,7 +846,7 @@ test('relayouts neighborhood graphs around the focused contact', () => {
assert.equal(center.y, 700)
})
test('keeps every direct relationship in neighborhood graphs even when display limits are small', () => {
test('prioritizes direct relationships in neighborhood graphs while respecting display limits', () => {
const center = makeGraphNode({
key: 'weixin:center',
platformId: 'center',
@@ -819,21 +913,25 @@ test('keeps every direct relationship in neighborhood graphs even when display l
coreNodeLimit: 10,
coreEdgeLimit: 10,
perNodeEdgeLimit: 10,
neighborhoodNodeLimit: 3,
neighborhoodNodeLimit: 4,
neighborhoodEdgeLimit: 3,
searchResultLimit: 20,
},
} satisfies PeopleRelationshipsSnapshot
const neighborhood = buildPeopleRelationshipsNeighborhoodGraph(snapshot, center.key)
const nodeKeys = new Set(neighborhood.nodes.map((node) => node.key))
const nodeKeys = neighborhood.nodes.map((node) => node.key)
const directEdgeKeys = new Set(neighborhood.edges.map((edge) => [edge.sourceKey, edge.targetKey].sort().join(':')))
for (const peer of directPeers) {
assert.equal(nodeKeys.has(peer.key), true)
assert.deepEqual(nodeKeys, [center.key, directPeers[0]!.key, directPeers[1]!.key, directPeers[2]!.key])
assert.equal(neighborhood.edges.length, 3)
for (const peer of directPeers.slice(0, 3)) {
assert.equal(directEdgeKeys.has([center.key, peer.key].sort().join(':')), true)
}
assert.equal(nodeKeys.has(unrelated.key), false)
for (const peer of directPeers.slice(3)) {
assert.equal(directEdgeKeys.has([center.key, peer.key].sort().join(':')), false)
}
assert.equal(nodeKeys.includes(unrelated.key), false)
})
test('returns only focused contact source groups in neighborhood communities', () => {
@@ -247,31 +247,57 @@ export function buildPeopleRelationshipsNeighborhoodGraph(
const relatedEdges = sortEdgesForDisplay(
snapshot.edges.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey)
)
const nodeKeys = new Set<string>([contactKey])
for (const edge of relatedEdges) {
nodeKeys.add(edge.sourceKey === contactKey ? edge.targetKey : edge.sourceKey)
}
const { directEdges, nodeKeys } = selectNeighborhoodDirectEdges(relatedEdges, contactKey, normalizedLimits)
const directEdgeIds = new Set(relatedEdges.map((edge) => edge.id))
const secondaryEdgeLimit = Math.max(0, normalizedLimits.neighborhoodEdgeLimit - relatedEdges.length)
const secondaryEdgeLimit = Math.max(0, normalizedLimits.neighborhoodEdgeLimit - directEdges.length)
const secondaryEdges = sortEdgesForDisplay(
snapshot.edges.filter(
(edge) => !directEdgeIds.has(edge.id) && nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey)
)
).slice(0, secondaryEdgeLimit)
const edges = sortEdgesForDisplay([...relatedEdges, ...secondaryEdges])
const edges = sortEdgesForDisplay([...directEdges, ...secondaryEdges])
const nodes = layoutNeighborhoodNodes(
snapshot.nodes.filter((node) => nodeKeys.has(node.key)),
relatedEdges,
directEdges,
contactKey
).sort(compareNodes)
return {
nodes,
edges,
communities: filterNeighborhoodCommunities(snapshot.communities, relatedEdges),
communities: filterNeighborhoodCommunities(snapshot.communities, directEdges),
}
}
function selectNeighborhoodDirectEdges(
relatedEdges: PeopleRelationshipGraphEdge[],
contactKey: string,
limits: Required<PeopleRelationshipsComputeLimits>
): { directEdges: PeopleRelationshipGraphEdge[]; nodeKeys: Set<string> } {
// 邻域图先按直接关系排序选择节点,避免高连接节点绕过节点/边上限。
const nodeKeys = new Set<string>([contactKey])
const directEdges: PeopleRelationshipGraphEdge[] = []
for (const edge of relatedEdges) {
if (directEdges.length >= limits.neighborhoodEdgeLimit) break
const neighborKey = getNeighborhoodNeighborKey(edge, contactKey)
if (!neighborKey) continue
if (!nodeKeys.has(neighborKey)) {
if (nodeKeys.size >= limits.neighborhoodNodeLimit) continue
nodeKeys.add(neighborKey)
}
directEdges.push(edge)
}
return { directEdges, nodeKeys }
}
function getNeighborhoodNeighborKey(edge: PeopleRelationshipGraphEdge, contactKey: string): string | null {
if (edge.sourceKey === contactKey) return edge.targetKey
if (edge.targetKey === contactKey) return edge.sourceKey
return null
}
function layoutNeighborhoodNodes(
nodes: PeopleRelationshipGraphNode[],
relatedEdges: PeopleRelationshipGraphEdge[],
@@ -624,6 +650,7 @@ function applyGroupFacts(
}
if (ownerNode) {
ownerNode.groupMessageCount += facts.ownerMessageCount
for (const fact of facts.ownerEdges) {
if (fact.coOccurrenceCount <= 0 && fact.replyInteractionCount <= 0) continue
const contactNode = getOrCreateNode(nodes, sessionId, meta, fact.contact)
@@ -1145,7 +1172,7 @@ function incrementPanoramaReason(diagnostics: PeopleRelationshipsDiagnostics, re
function buildOwnerKeyForContribution(nodeByKey: Map<string, NodeAccumulator>, contactKey: string): string {
const contact = nodeByKey.get(contactKey)
return contact ? buildOwnerKey(contact.platform) : ''
return contact ? buildOwnerKey() : ''
}
function toGraphEdge(edge: EdgeAccumulator, weight: number): PeopleRelationshipGraphEdge {
@@ -1310,7 +1337,7 @@ function getOrCreateOwnerNode(
meta: PeopleRelationshipsSessionMetaFacts
): NodeAccumulator | null {
if (!meta.owner) return null
const key = buildOwnerKey(meta.platform)
const key = buildOwnerKey()
const existing = nodes.get(key)
if (existing) {
mergeContactIdentity(existing, meta.owner)
@@ -1434,10 +1461,8 @@ function buildContactKey(platform: ChatPlatform, platformId: string, sessionId?:
: `${normalizedPlatform}:${normalizedPlatformId}`
}
function buildOwnerKey(platform: ChatPlatform): string {
const normalizedPlatform = platform.trim()
if (!normalizedPlatform) throw new Error('platform is required')
return `${OWNER_KEY_PREFIX}:${normalizedPlatform}`
function buildOwnerKey(): string {
return OWNER_KEY_PREFIX
}
function mergeContactIdentity(acc: NodeAccumulator, contact: ContactMemberRef): void {
@@ -9,8 +9,11 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import type { PeopleRelationshipGraphEdge, PeopleRelationshipGraphNode } from '@openchatlab/shared-types'
import type { SessionRuntimeAdapter } from '../../adapters'
import { getContactsDir } from '../../contacts/paths'
import { writeContactOverrides } from '../../contacts/overrides'
import { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, type PeopleRelationshipsSnapshot } from './compute'
import { createPeopleRelationshipsService } from './service'
@@ -18,6 +21,21 @@ function makeTempDir(): string {
return fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-service-'))
}
function makePathProvider(rootDir: string): PathProvider {
return {
getSystemDir: () => rootDir,
getUserDataDir: () => path.join(rootDir, 'data'),
getDatabaseDir: () => path.join(rootDir, 'data', 'databases'),
getVectorDir: () => path.join(rootDir, 'vector'),
getAiDataDir: () => path.join(rootDir, 'ai'),
getSettingsDir: () => path.join(rootDir, 'settings'),
getCacheDir: () => path.join(rootDir, 'cache'),
getTempDir: () => path.join(rootDir, 'temp'),
getLogsDir: () => path.join(rootDir, 'logs'),
getDownloadsDir: () => path.join(rootDir, 'downloads'),
}
}
function makeNode(overrides: Partial<PeopleRelationshipGraphNode> & { key: string }): PeopleRelationshipGraphNode {
return {
key: overrides.key,
@@ -269,6 +287,61 @@ test('returns a close relationships graph with owner, all friends, and top score
}
})
test('excludes silent non-friend group roster members from the close relationships graph', () => {
const dir = makeTempDir()
try {
const service = createPeopleRelationshipsService({
adapter: makeAdapter(),
systemDir: dir,
runner: async () => {
throw new Error('runner should not be called for fresh injected snapshot')
},
})
const snapshot = makeSnapshot(makeFreshSignature())
const connectedGroupmate = makeNode({
key: 'weixin:connected-groupmate',
displayName: 'Connected Groupmate',
pool: 'non_friend',
rank: 70,
score: 20,
groupMessageCount: 0,
commonGroupCount: 1,
})
const activeGroupmate = makeNode({
key: 'weixin:active-groupmate',
displayName: 'Active Groupmate',
pool: 'non_friend',
rank: 71,
score: 15,
groupMessageCount: 3,
commonGroupCount: 1,
})
const silentGroupmates = Array.from({ length: 55 }, (_, index) =>
makeNode({
key: `weixin:silent-groupmate-${index + 1}`,
displayName: `Silent Groupmate ${index + 1}`,
pool: 'non_friend',
rank: 10 + index,
score: 100 - index,
groupMessageCount: 0,
commonGroupCount: 1,
})
)
snapshot.nodes = [...snapshot.nodes, connectedGroupmate, activeGroupmate, ...silentGroupmates]
snapshot.edges = [...snapshot.edges, makeEdge('owner:weixin', connectedGroupmate.key, 8)]
service.replaceSnapshotForTests?.(snapshot)
const response = service.getGraph({ acceptStale: true, graphScope: 'close' })
const keys = response.graph.nodes.map((node) => node.key)
assert.ok(keys.includes(connectedGroupmate.key))
assert.ok(keys.includes(activeGroupmate.key))
assert.ok(!keys.some((key) => key.startsWith('weixin:silent-groupmate-')))
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('returns a friends relationships graph without groupmate nodes while keeping full search results', () => {
const dir = makeTempDir()
try {
@@ -329,6 +402,119 @@ test('returns a friends relationships graph without groupmate nodes while keepin
}
})
test('applies manual contact friend overrides to people relationships graph scopes', () => {
const dir = makeTempDir()
try {
const pathProvider = makePathProvider(dir)
const manualFriendKey = 'weixin:manual-groupmate'
writeContactOverrides(getContactsDir(pathProvider.getUserDataDir()), {
version: 1,
manualFriends: {
[manualFriendKey]: {
key: manualFriendKey,
createdAt: 1800000000,
updatedAt: 1800000000,
},
},
})
const service = createPeopleRelationshipsService({
adapter: makeAdapter(),
pathProvider,
runner: async () => {
throw new Error('runner should not be called for fresh injected snapshot')
},
})
const snapshot = makeSnapshot(makeFreshSignature())
const manualGroupmate = makeNode({
key: manualFriendKey,
displayName: 'Manual Groupmate',
pool: 'non_friend',
rank: 6,
score: 50,
searchText: 'manual groupmate',
})
snapshot.nodes = [...snapshot.nodes, manualGroupmate]
snapshot.edges = [...snapshot.edges, makeEdge('owner:weixin', manualGroupmate.key, 7)]
service.replaceSnapshotForTests?.(snapshot)
const response = service.getGraph({ acceptStale: true, graphScope: 'friends', query: 'manual' })
const manualNode = response.graph.nodes.find((node) => node.key === manualFriendKey)
const manualSearchResult = response.searchResults.find((node) => node.key === manualFriendKey)
assert.equal(manualNode?.pool, 'friend')
assert.equal(manualNode?.friendSource, 'manual')
assert.equal(manualSearchResult?.pool, 'friend')
assert.equal(
response.graph.edges.some((edge) => edge.sourceKey === manualFriendKey || edge.targetKey === manualFriendKey),
true
)
const neighborhood = service.getNeighborhood(manualFriendKey, { acceptStale: true })
assert.equal(neighborhood.contact?.pool, 'friend')
assert.equal(neighborhood.contact?.friendSource, 'manual')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('adds missing manual friends to the default panorama graph', () => {
const dir = makeTempDir()
try {
const pathProvider = makePathProvider(dir)
const manualFriendKey = 'weixin:manual-panorama'
writeContactOverrides(getContactsDir(pathProvider.getUserDataDir()), {
version: 1,
manualFriends: {
[manualFriendKey]: {
key: manualFriendKey,
createdAt: 1800000000,
updatedAt: 1800000000,
},
},
})
const service = createPeopleRelationshipsService({
adapter: makeAdapter(),
pathProvider,
runner: async () => {
throw new Error('runner should not be called for fresh injected snapshot')
},
})
const snapshot = makeSnapshot(makeFreshSignature())
const manualGroupmate = makeNode({
key: manualFriendKey,
displayName: 'Manual Panorama',
pool: 'non_friend',
rank: 6,
score: 50,
communityId: 'group:manual',
searchText: 'manual panorama',
})
snapshot.nodes = [...snapshot.nodes, manualGroupmate]
snapshot.edges = [...snapshot.edges, makeEdge('weixin:alice', manualGroupmate.key, 7)]
snapshot.communities = [
...snapshot.communities,
{ id: 'group:manual', label: 'Manual Group', size: 1, x: 0, y: 0, color: '#facc15' },
]
service.replaceSnapshotForTests?.(snapshot)
const response = service.getGraph({ acceptStale: true })
const manualNode = response.graph.nodes.find((node) => node.key === manualFriendKey)
assert.equal(manualNode?.pool, 'friend')
assert.equal(manualNode?.friendSource, 'manual')
assert.equal(
response.graph.edges.some((edge) => edge.sourceKey === manualFriendKey || edge.targetKey === manualFriendKey),
true
)
assert.equal(
response.graph.communities.some((community) => community.id === 'group:manual'),
true
)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('returns neighborhood graph for a searched node outside the core graph', () => {
const dir = makeTempDir()
try {
@@ -2,6 +2,7 @@ import type { PathProvider } from '@openchatlab/core'
import type {
ContactsTimeRangePreset,
PeopleRelationshipCommunity,
PeopleRelationshipGraphNode,
PeopleRelationshipsGraphData,
PeopleRelationshipsCacheState,
PeopleRelationshipsDiagnostics,
@@ -14,8 +15,11 @@ import type {
import type { RuntimeIdentity } from '../../../data-dir-compat'
import { appLogger } from '../../../logging/app-logger'
import type { SessionRuntimeAdapter } from '../../adapters'
import { readContactOverrides } from '../../contacts/overrides'
import { getContactsDir } from '../../contacts/paths'
import {
buildPeopleRelationshipsNeighborhoodGraph,
compareEdges,
compareNodes,
PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION,
type PeopleRelationshipsComputeProgress,
@@ -119,7 +123,7 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
const signature = buildPeopleRelationshipsSignature(this.deps.adapter, timeRangePreset)
const cacheStatus = this.getCacheStatus(signature, timeRangePreset)
if (this.shouldStartTaskFromRead(options, cacheStatus)) this.ensureTaskStarted(signature, timeRangePreset)
const snapshot = this.getSnapshot(timeRangePreset)
const snapshot = this.getSnapshotForResponse(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale)
const graph = includeSnapshot && snapshot ? buildPeopleRelationshipsNeighborhoodGraph(snapshot, key) : emptyGraph()
@@ -279,7 +283,7 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
): PeopleRelationshipsGraphResponse {
const timeRangePreset = normalizePeopleRelationshipsTimeRangePreset(options.timeRangePreset)
const graphScope = normalizePeopleRelationshipsGraphScope(options.graphScope)
const snapshot = this.getSnapshot(timeRangePreset)
const snapshot = this.getSnapshotForResponse(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale)
const graph = includeSnapshot && snapshot ? buildGraphForScope(snapshot, graphScope) : emptyGraph()
@@ -320,6 +324,15 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
return this.snapshots.get(timeRangePreset) ?? null
}
private getSnapshotForResponse(timeRangePreset: ContactsTimeRangePreset): PeopleRelationshipsSnapshot | null {
const snapshot = this.getSnapshot(timeRangePreset)
if (!snapshot || !this.deps.pathProvider) return snapshot
return applyManualFriendOverridesToSnapshot(
snapshot,
readContactOverrides(getContactsDir(this.deps.pathProvider.getUserDataDir()))
)
}
private now(): number {
return this.deps.now?.() ?? Date.now()
}
@@ -362,8 +375,15 @@ function buildCloseRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): Pe
if (node.kind === 'owner' || node.pool === 'friend') selectedKeys.add(node.key)
}
const edgeConnectedKeys = buildEdgeConnectedNodeKeys(snapshot.edges)
const topGroupmates = snapshot.nodes
.filter((node) => node.kind !== 'owner' && node.pool !== 'friend' && node.score > 0)
.filter(
(node) =>
node.kind !== 'owner' &&
node.pool !== 'friend' &&
node.score > 0 &&
hasCloseGraphNonFriendActivity(node, edgeConnectedKeys)
)
.sort(compareCloseGroupmates)
.slice(0, CLOSE_GRAPH_NON_FRIEND_LIMIT)
for (const node of topGroupmates) selectedKeys.add(node.key)
@@ -378,6 +398,23 @@ function buildCloseRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): Pe
}
}
function buildEdgeConnectedNodeKeys(edges: PeopleRelationshipsSnapshot['edges']): Set<string> {
const keys = new Set<string>()
for (const edge of edges) {
keys.add(edge.sourceKey)
keys.add(edge.targetKey)
}
return keys
}
function hasCloseGraphNonFriendActivity(
node: PeopleRelationshipsSnapshot['nodes'][number],
edgeConnectedKeys: Set<string>
): boolean {
// close 图只展示有真实互动的非好友:共同群 roster 本身不算互动信号。
return node.groupMessageCount > 0 || edgeConnectedKeys.has(node.key)
}
function compareCloseGroupmates(
a: PeopleRelationshipsSnapshot['nodes'][number],
b: PeopleRelationshipsSnapshot['nodes'][number]
@@ -419,6 +456,7 @@ function buildSearchResults(
platformId: node.platformId,
avatar: node.avatar,
pool: node.pool,
friendSource: node.friendSource,
score: node.score,
rank: node.rank,
communityId: node.communityId,
@@ -426,6 +464,82 @@ function buildSearchResults(
}))
}
function applyManualFriendOverridesToSnapshot(
snapshot: PeopleRelationshipsSnapshot,
overrides: ReturnType<typeof readContactOverrides>
): PeopleRelationshipsSnapshot {
const manualFriendKeys = new Set(Object.keys(overrides.manualFriends))
if (manualFriendKeys.size === 0) return snapshot
// 手动好友是本机覆盖数据,不写入派生 snapshot;响应阶段克隆节点,确保各图谱 scope 即时生效。
const applyOverride = (node: PeopleRelationshipGraphNode): PeopleRelationshipGraphNode => {
if (node.kind === 'owner' || node.pool === 'friend' || !manualFriendKeys.has(node.key)) return node
return {
...node,
pool: 'friend',
friendSource: 'manual',
}
}
const nodes = snapshot.nodes.map(applyOverride)
const nodeByKey = new Map(nodes.map((node) => [node.key, node]))
const graphNodes = includeManualFriendsInPanoramaGraphNodes(
snapshot.graph.nodes.map((node) => nodeByKey.get(node.key) ?? applyOverride(node)),
nodes,
manualFriendKeys
)
const graphNodeKeys = new Set(graphNodes.map((node) => node.key))
const graphEdges = includeManualFriendPanoramaEdges(
snapshot.graph.edges,
snapshot.edges,
graphNodeKeys,
manualFriendKeys
)
return {
...snapshot,
nodes,
graph: {
...snapshot.graph,
nodes: graphNodes,
edges: graphEdges,
communities: filterCommunitiesForNodes(snapshot.communities, graphNodes),
},
}
}
function includeManualFriendsInPanoramaGraphNodes(
graphNodes: PeopleRelationshipGraphNode[],
nodes: PeopleRelationshipGraphNode[],
manualFriendKeys: Set<string>
): PeopleRelationshipGraphNode[] {
const graphNodeKeys = new Set(graphNodes.map((node) => node.key))
const result = [...graphNodes]
for (const node of nodes) {
if (!manualFriendKeys.has(node.key) || graphNodeKeys.has(node.key) || node.kind === 'owner') continue
result.push(node)
graphNodeKeys.add(node.key)
}
return result.sort(compareNodes)
}
function includeManualFriendPanoramaEdges(
graphEdges: PeopleRelationshipsGraphData['edges'],
edges: PeopleRelationshipsSnapshot['edges'],
graphNodeKeys: Set<string>,
manualFriendKeys: Set<string>
): PeopleRelationshipsGraphData['edges'] {
// 手动好友是用户显式修正,只补它和当前全景节点之间的边,避免把被裁剪的全量图重新展开。
const edgeIds = new Set(graphEdges.map((edge) => edge.id))
const result = [...graphEdges]
for (const edge of edges) {
if (edgeIds.has(edge.id)) continue
if (!graphNodeKeys.has(edge.sourceKey) || !graphNodeKeys.has(edge.targetKey)) continue
if (!manualFriendKeys.has(edge.sourceKey) && !manualFriendKeys.has(edge.targetKey)) continue
result.push(edge)
edgeIds.add(edge.id)
}
return result.sort(compareEdges)
}
function emptyGraph(): PeopleRelationshipsGraphData {
return { nodes: [], edges: [], communities: [] }
}
+1
View File
@@ -474,6 +474,7 @@ export interface PeopleRelationshipsSearchResult {
platformId: string
avatar: string | null
pool: ContactPool
friendSource?: ContactFriendSource
score: number
rank: number
communityId: string
@@ -8,6 +8,7 @@ import {
buildRelationshipVisibleGraphForSelection,
buildRelationshipVisibleLabelKeys,
} from '../relationship-galaxy-connections'
import { destroyRemovedPixiChildren } from '../relationship-galaxy-pixi-layers'
import {
buildRelationshipGalaxy2DSafeCenter,
buildRelationshipGalaxy2DSafeFitScale,
@@ -294,7 +295,8 @@ function drawHighlightEdges() {
function renderGraph(shouldFit = false) {
if (!viewport) return
viewport.removeChildren()
destroyRemovedPixiChildren(viewport)
activeRipples.length = 0
renderedNodePositions.clear()
animatedNodes.length = 0
neighborKeysOf.clear()
+32 -3
View File
@@ -19,7 +19,12 @@ import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
import { usePeoplePageHeader } from '../people-page-header'
import { buildRelationshipConnectionRanking, RELATED_CONTACTS_VISIBLE_LIMIT } from './relationship-galaxy-connections'
import { resolveRelationshipGalaxyCanvasSelectedKey } from './relationship-galaxy-selection'
import { shouldShowFocusConnectionsAction } from './relationship-galaxy-state'
import {
resolveRelationshipGalaxyActiveTask,
resolveRelationshipGalaxyEmptyText,
resolveRelationshipGalaxyPollingAction,
shouldShowFocusConnectionsAction,
} from './relationship-galaxy-state'
import RelationshipGalaxyCanvas from './components/RelationshipGalaxyCanvas.vue'
import RelationshipGalaxyThreeCanvas from './components/RelationshipGalaxyThreeCanvas.vue'
@@ -105,7 +110,13 @@ const graphScopeTabs = computed(() => [
const activeGraph = computed(() => neighborhoodResponse.value?.graph ?? graphResponse.value?.graph ?? EMPTY_GRAPH)
const isNeighborhoodMode = computed(() => Boolean(neighborhoodResponse.value))
const hasGraph = computed(() => activeGraph.value.nodes.length > 0)
const task = computed(() => graphResponse.value?.task ?? neighborhoodResponse.value?.task ?? null)
const task = computed(() =>
resolveRelationshipGalaxyActiveTask({
graphTask: graphResponse.value?.task ?? null,
neighborhoodTask: neighborhoodResponse.value?.task ?? null,
isNeighborhoodMode: isNeighborhoodMode.value,
})
)
const isTaskRunning = computed(() => task.value?.status === 'running')
const isTaskFailed = computed(() => task.value?.status === 'failed')
const cacheStatus = computed(() => graphResponse.value?.cache.status ?? neighborhoodResponse.value?.cache.status)
@@ -159,6 +170,14 @@ const statusText = computed(() => {
if (isTaskFailed.value) return task.value?.lastError || t('relationships.task.failed')
return ''
})
const emptyStateText = computed(() =>
resolveRelationshipGalaxyEmptyText({
loadError: loadError.value,
isTaskFailed: isTaskFailed.value,
statusText: statusText.value,
emptyText: t('relationships.empty'),
})
)
function formatNumber(value: number): string {
return numberFormatter.value.format(value)
@@ -232,6 +251,10 @@ function syncPolling(nextTask: PeopleRelationshipsTaskState | undefined) {
async function loadGraph(options: { silent?: boolean; preserveNeighborhood?: boolean } = {}) {
const requestId = graphRequestId.value + 1
graphRequestId.value = requestId
const previousTaskStatus = task.value?.status ?? null
const neighborhoodContactKey = options.preserveNeighborhood
? (neighborhoodResponse.value?.contact?.key ?? null)
: null
if (!options.silent) isLoading.value = true
if (!options.preserveNeighborhood) {
cancelNeighborhoodLoad()
@@ -255,7 +278,13 @@ async function loadGraph(options: { silent?: boolean; preserveNeighborhood?: boo
canvasSelectedKey.value = null
isDetailPanelOpen.value = false
}
const pollingAction = resolveRelationshipGalaxyPollingAction({
previousTaskStatus,
nextTaskStatus: next.task?.status ?? null,
neighborhoodContactKey,
})
syncPolling(next.task)
if (pollingAction.type === 'refresh-neighborhood') void loadNeighborhood(pollingAction.key)
} catch (error) {
if (requestId !== graphRequestId.value) return
loadError.value = String(error)
@@ -651,7 +680,7 @@ onBeforeUnmount(() => {
v-else-if="!hasGraph"
class="absolute inset-0 flex items-center justify-center text-sm font-medium text-gray-400"
>
{{ loadError || t('relationships.empty') }}
{{ emptyStateText }}
</div>
<aside
@@ -0,0 +1,33 @@
/**
* Tests for Pixi relationship galaxy layer cleanup.
*
* Run: pnpm test -- src/pages/people/relationships/relationship-galaxy-pixi-layers.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { destroyRemovedPixiChildren } from './relationship-galaxy-pixi-layers'
test('destroys removed Pixi children recursively when clearing a container', () => {
const destroyedOptions: unknown[] = []
const removedChildren = [
{
destroy: (options?: unknown) => {
destroyedOptions.push(options)
},
},
{
destroy: (options?: unknown) => {
destroyedOptions.push(options)
},
},
]
const container = {
removeChildren: () => removedChildren,
}
destroyRemovedPixiChildren(container)
assert.deepEqual(destroyedOptions, [{ children: true }, { children: true }])
})
@@ -0,0 +1,14 @@
export interface PixiRemovableChildrenContainer {
removeChildren(): PixiDestroyableChild[]
}
export interface PixiDestroyableChild {
destroy(options?: { children?: boolean }): void
}
export function destroyRemovedPixiChildren(container: PixiRemovableChildrenContainer): void {
// Pixi 的 removeChildren 只脱离 display objects;重绘前需要递归销毁旧层,避免 listener/GPU 资源残留。
for (const child of container.removeChildren()) {
child.destroy({ children: true })
}
}
@@ -4,7 +4,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldShowFocusConnectionsAction } from './relationship-galaxy-state'
import {
resolveRelationshipGalaxyActiveTask,
resolveRelationshipGalaxyEmptyText,
resolveRelationshipGalaxyPollingAction,
shouldShowFocusConnectionsAction,
} from './relationship-galaxy-state'
test('shows focus connections action only after selecting a panorama node', () => {
assert.equal(shouldShowFocusConnectionsAction({ selectedKey: null, isNeighborhoodMode: false }), false)
@@ -26,3 +31,84 @@ test('shows focus connections action only after selecting a panorama node', () =
true
)
})
test('refreshes the open neighborhood after a polling recompute succeeds', () => {
assert.deepEqual(
resolveRelationshipGalaxyPollingAction({
previousTaskStatus: 'running',
nextTaskStatus: 'succeeded',
neighborhoodContactKey: 'weixin:alice',
}),
{ type: 'refresh-neighborhood', key: 'weixin:alice' }
)
assert.deepEqual(
resolveRelationshipGalaxyPollingAction({
previousTaskStatus: 'running',
nextTaskStatus: 'running',
neighborhoodContactKey: 'weixin:alice',
}),
{ type: 'continue-polling' }
)
assert.deepEqual(
resolveRelationshipGalaxyPollingAction({
previousTaskStatus: 'running',
nextTaskStatus: 'succeeded',
neighborhoodContactKey: null,
}),
{ type: 'stop-polling' }
)
})
test('prefers a running neighborhood task over an older graph task while neighborhood is active', () => {
const graphTask = { id: 'graph-old', status: 'succeeded' as const }
const neighborhoodTask = { id: 'neighborhood-new', status: 'running' as const }
const activeTask = resolveRelationshipGalaxyActiveTask({
graphTask,
neighborhoodTask,
isNeighborhoodMode: true,
})
assert.equal(activeTask, neighborhoodTask)
assert.deepEqual(
resolveRelationshipGalaxyPollingAction({
previousTaskStatus: activeTask?.status ?? null,
nextTaskStatus: 'succeeded',
neighborhoodContactKey: 'weixin:alice',
}),
{ type: 'refresh-neighborhood', key: 'weixin:alice' }
)
})
test('prefers failed task text over generic empty relationship graph text', () => {
assert.equal(
resolveRelationshipGalaxyEmptyText({
loadError: '',
isTaskFailed: true,
statusText: 'people relationships worker failed',
emptyText: 'No relationship graph data',
}),
'people relationships worker failed'
)
assert.equal(
resolveRelationshipGalaxyEmptyText({
loadError: 'Network request failed',
isTaskFailed: true,
statusText: 'people relationships worker failed',
emptyText: 'No relationship graph data',
}),
'Network request failed'
)
assert.equal(
resolveRelationshipGalaxyEmptyText({
loadError: '',
isTaskFailed: false,
statusText: 'background state',
emptyText: 'No relationship graph data',
}),
'No relationship graph data'
)
})
@@ -9,3 +9,62 @@ export function shouldShowFocusConnectionsAction(state: FocusConnectionsActionSt
if (!state.isNeighborhoodMode) return true
return state.selectedKey !== state.neighborhoodContactKey
}
export type RelationshipGalaxyPollingTaskStatus = 'idle' | 'running' | 'succeeded' | 'failed' | 'superseded'
export interface RelationshipGalaxyTaskLike {
status: RelationshipGalaxyPollingTaskStatus
}
export interface RelationshipGalaxyActiveTaskState<
TGraphTask extends RelationshipGalaxyTaskLike,
TNeighborhoodTask extends RelationshipGalaxyTaskLike,
> {
graphTask?: TGraphTask | null
neighborhoodTask?: TNeighborhoodTask | null
isNeighborhoodMode: boolean
}
export function resolveRelationshipGalaxyActiveTask<
TGraphTask extends RelationshipGalaxyTaskLike,
TNeighborhoodTask extends RelationshipGalaxyTaskLike,
>(state: RelationshipGalaxyActiveTaskState<TGraphTask, TNeighborhoodTask>): TGraphTask | TNeighborhoodTask | null {
if (state.neighborhoodTask?.status === 'running') return state.neighborhoodTask
if (state.graphTask?.status === 'running') return state.graphTask
if (state.isNeighborhoodMode && state.neighborhoodTask) return state.neighborhoodTask
return state.graphTask ?? state.neighborhoodTask ?? null
}
export type RelationshipGalaxyPollingAction =
| { type: 'continue-polling' }
| { type: 'refresh-neighborhood'; key: string }
| { type: 'stop-polling' }
export interface RelationshipGalaxyPollingState {
previousTaskStatus?: RelationshipGalaxyPollingTaskStatus | null
nextTaskStatus?: RelationshipGalaxyPollingTaskStatus | null
neighborhoodContactKey?: string | null
}
export function resolveRelationshipGalaxyPollingAction(
state: RelationshipGalaxyPollingState
): RelationshipGalaxyPollingAction {
if (state.nextTaskStatus === 'running') return { type: 'continue-polling' }
if (state.previousTaskStatus === 'running' && state.nextTaskStatus === 'succeeded' && state.neighborhoodContactKey) {
return { type: 'refresh-neighborhood', key: state.neighborhoodContactKey }
}
return { type: 'stop-polling' }
}
export interface RelationshipGalaxyEmptyTextState {
loadError: string
isTaskFailed: boolean
statusText: string
emptyText: string
}
export function resolveRelationshipGalaxyEmptyText(state: RelationshipGalaxyEmptyTextState): string {
if (state.loadError) return state.loadError
if (state.isTaskFailed && state.statusText) return state.statusText
return state.emptyText
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Regression tests for worker bundle entry configuration.
*
* Run: pnpm test -- tests/build-worker-entries.test.ts
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function readProjectFile(path: string): string {
return readFileSync(path, 'utf-8')
}
test('CLI bundle emits the people relationships worker entry expected by the runtime resolver', () => {
const config = readProjectFile('apps/cli/tsup.config.ts')
assert.match(config, /['"]people-relationships-worker['"]\s*:/)
assert.match(config, /packages\/node-runtime\/src\/services\/people\/relationships\/worker-entry\.ts/)
})
test('Desktop bundle emits the people relationships worker entry expected by the runtime resolver', () => {
const config = readProjectFile('apps/desktop/electron.vite.config.ts')
assert.match(config, /['"]people-relationships-worker['"]\s*:/)
assert.match(config, /packages\/node-runtime\/src\/services\/people\/relationships\/worker-entry\.ts/)
})