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 }}