mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
1 parent
9c2ab48b1c
commit
d3d2661882
14 files changed
+707
-28
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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: [] }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user