From d3d266188208fa9086bee5528b7c79951b082015 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 29 Jun 2026 22:39:35 +0800 Subject: [PATCH] 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. --- apps/cli/tsup.config.ts | 1 + apps/desktop/electron.vite.config.ts | 4 + .../people/relationships/compute.test.ts | 110 ++++++++++- .../services/people/relationships/compute.ts | 53 +++-- .../people/relationships/service.test.ts | 186 ++++++++++++++++++ .../services/people/relationships/service.ts | 120 ++++++++++- packages/shared-types/index.ts | 1 + .../components/RelationshipGalaxyCanvas.vue | 4 +- src/pages/people/relationships/index.vue | 35 +++- .../relationship-galaxy-pixi-layers.test.ts | 33 ++++ .../relationship-galaxy-pixi-layers.ts | 14 ++ .../relationship-galaxy-state.test.ts | 88 ++++++++- .../relationship-galaxy-state.ts | 59 ++++++ tests/build-worker-entries.test.ts | 27 +++ 14 files changed, 707 insertions(+), 28 deletions(-) create mode 100644 src/pages/people/relationships/relationship-galaxy-pixi-layers.test.ts create mode 100644 src/pages/people/relationships/relationship-galaxy-pixi-layers.ts create mode 100644 tests/build-worker-entries.test.ts diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 3a4a0d44..09a70a1c 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -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', diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 5e0e5238..b151b716 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -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' + ), }, }, }, diff --git a/packages/node-runtime/src/services/people/relationships/compute.test.ts b/packages/node-runtime/src/services/people/relationships/compute.test.ts index 5b449fd5..fe4db785 100644 --- a/packages/node-runtime/src/services/people/relationships/compute.test.ts +++ b/packages/node-runtime/src/services/people/relationships/compute.test.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', () => { diff --git a/packages/node-runtime/src/services/people/relationships/compute.ts b/packages/node-runtime/src/services/people/relationships/compute.ts index 4fa55939..d0e08fdf 100644 --- a/packages/node-runtime/src/services/people/relationships/compute.ts +++ b/packages/node-runtime/src/services/people/relationships/compute.ts @@ -247,31 +247,57 @@ export function buildPeopleRelationshipsNeighborhoodGraph( const relatedEdges = sortEdgesForDisplay( snapshot.edges.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey) ) - const nodeKeys = new Set([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 +): { directEdges: PeopleRelationshipGraphEdge[]; nodeKeys: Set } { + // 邻域图先按直接关系排序选择节点,避免高连接节点绕过节点/边上限。 + const nodeKeys = new Set([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, 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 { diff --git a/packages/node-runtime/src/services/people/relationships/service.test.ts b/packages/node-runtime/src/services/people/relationships/service.test.ts index 18c447c6..a7b6b936 100644 --- a/packages/node-runtime/src/services/people/relationships/service.test.ts +++ b/packages/node-runtime/src/services/people/relationships/service.test.ts @@ -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 & { 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 { diff --git a/packages/node-runtime/src/services/people/relationships/service.ts b/packages/node-runtime/src/services/people/relationships/service.ts index 50fb9dbd..f4a7467e 100644 --- a/packages/node-runtime/src/services/people/relationships/service.ts +++ b/packages/node-runtime/src/services/people/relationships/service.ts @@ -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 { + const keys = new Set() + for (const edge of edges) { + keys.add(edge.sourceKey) + keys.add(edge.targetKey) + } + return keys +} + +function hasCloseGraphNonFriendActivity( + node: PeopleRelationshipsSnapshot['nodes'][number], + edgeConnectedKeys: Set +): 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 +): 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 +): 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, + manualFriendKeys: Set +): 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: [] } } diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 38ff9dff..6126d3ed 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -474,6 +474,7 @@ export interface PeopleRelationshipsSearchResult { platformId: string avatar: string | null pool: ContactPool + friendSource?: ContactFriendSource score: number rank: number communityId: string diff --git a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue index 2b873121..4ffe9372 100644 --- a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue +++ b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue @@ -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() diff --git a/src/pages/people/relationships/index.vue b/src/pages/people/relationships/index.vue index 7d248573..a7025f1a 100644 --- a/src/pages/people/relationships/index.vue +++ b/src/pages/people/relationships/index.vue @@ -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 }}