mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(owner): exclude sessions without current user
This commit is contained in:
1 parent
ae1ed4a946
commit
2747a9f1d0
35 files changed
+359
-115
No files matched your search
@@ -701,13 +701,13 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
current.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`)
|
||||
const other = createSessionDb()
|
||||
other.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`)
|
||||
const dismissedOnly = createSessionDb()
|
||||
dismissedOnly.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'; DELETE FROM member`)
|
||||
const excludedOnly = createSessionDb()
|
||||
excludedOnly.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'; DELETE FROM member`)
|
||||
|
||||
const dbs = new Map<string, DatabaseAdapter>([
|
||||
['chat-1', current],
|
||||
['chat-2', other],
|
||||
['chat-3', dismissedOnly],
|
||||
['chat-3', excludedOnly],
|
||||
])
|
||||
const ctx = createTestContext(dbs)
|
||||
ctx.preferencesManager = new PreferencesManager(prefDir)
|
||||
@@ -716,15 +716,19 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
await routeApp.ready()
|
||||
|
||||
try {
|
||||
// Dismiss the prompt for chat-3 (no matching member there)
|
||||
const dismissResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/dismiss-prompt' })
|
||||
assert.equal(dismissResp.statusCode, 200)
|
||||
assert.deepEqual(dismissResp.json(), { success: true })
|
||||
// Exclude chat-3 because the user's messages are absent.
|
||||
const excludeResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/exclude' })
|
||||
assert.equal(excludeResp.statusCode, 200)
|
||||
assert.deepEqual(excludeResp.json(), { success: true })
|
||||
|
||||
// apply-profile before any profile exists reports no_profile and the dismissed flag
|
||||
const sessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
const excludedSession = sessionsResp.json().find((session: { id: string }) => session.id === 'chat-3')
|
||||
assert.equal(excludedSession.ownerExcluded, true)
|
||||
|
||||
// Excluded sessions are not eligible for automatic profile application.
|
||||
const earlyApply = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/apply-profile' })
|
||||
assert.equal(earlyApply.statusCode, 200)
|
||||
assert.deepEqual(earlyApply.json(), { applied: false, reason: 'no_profile', dismissed: true })
|
||||
assert.deepEqual(earlyApply.json(), { applied: false, reason: 'excluded', excluded: true })
|
||||
|
||||
// Manual selection writes owner, saves profile and batch-applies to chat-2
|
||||
const selectResp = await routeApp.inject({
|
||||
@@ -745,12 +749,12 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
// apply-profile on an already-owned session reports already_set
|
||||
const applyResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-2/owner/apply-profile' })
|
||||
assert.equal(applyResp.statusCode, 200)
|
||||
assert.deepEqual(applyResp.json(), { applied: false, ownerId: 'alice', reason: 'already_set', dismissed: false })
|
||||
assert.deepEqual(applyResp.json(), { applied: false, ownerId: 'alice', reason: 'already_set', excluded: false })
|
||||
} finally {
|
||||
await routeApp.close()
|
||||
current.close()
|
||||
other.close()
|
||||
dismissedOnly.close()
|
||||
excludedOnly.close()
|
||||
fs.rmSync(prefDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createGlobalInsightService, createTimeInvestmentService } from '@openchatlab/node-runtime'
|
||||
import { createGlobalInsightService, createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime'
|
||||
import type { RuntimeRouteContext } from '../../context/runtime'
|
||||
import type { ServiceRouteContext } from '../../context/services'
|
||||
|
||||
@@ -14,9 +14,11 @@ type GlobalInsightRouteContext = Pick<
|
||||
RuntimeRouteContext,
|
||||
'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'
|
||||
> &
|
||||
Pick<ServiceRouteContext, 'globalInsightService' | 'timeInvestmentService'>
|
||||
Pick<ServiceRouteContext, 'globalInsightService' | 'timeInvestmentService' | 'preferencesManager'>
|
||||
|
||||
export function registerGlobalInsightRoutes(server: FastifyInstance, ctx: GlobalInsightRouteContext): void {
|
||||
const preferences = ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir())
|
||||
const getExcludedSessionIds = () => preferences.load().ownerExcludedSessionIds
|
||||
const service =
|
||||
ctx.globalInsightService ??
|
||||
createGlobalInsightService({
|
||||
@@ -24,6 +26,7 @@ export function registerGlobalInsightRoutes(server: FastifyInstance, ctx: Global
|
||||
pathProvider: ctx.pathProvider,
|
||||
runtimeIdentity: ctx.runtimeIdentity,
|
||||
nativeBinding: ctx.nativeBinding,
|
||||
getExcludedSessionIds,
|
||||
})
|
||||
const timeInvestmentService =
|
||||
ctx.timeInvestmentService ??
|
||||
@@ -32,6 +35,7 @@ export function registerGlobalInsightRoutes(server: FastifyInstance, ctx: Global
|
||||
pathProvider: ctx.pathProvider,
|
||||
runtimeIdentity: ctx.runtimeIdentity,
|
||||
nativeBinding: ctx.nativeBinding,
|
||||
getExcludedSessionIds,
|
||||
})
|
||||
server.addHook('onClose', async () => {
|
||||
await Promise.all([service.close(), timeInvestmentService.close()])
|
||||
|
||||
@@ -26,17 +26,22 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
|
||||
server.get('/_web/sessions', async () => {
|
||||
const aiChatCounts = ctx.aiChatManager?.getAIChatCountsBySession()
|
||||
const excludedSessionIds = new Set(preferences().load().ownerExcludedSessionIds)
|
||||
return sessionService.listAnalysisSessions(adapter, {
|
||||
resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir),
|
||||
enrichSession: aiChatCounts?.size
|
||||
? (dto) => ({ ...dto, aiConversationCount: aiChatCounts.get(dto.id) ?? 0 })
|
||||
: undefined,
|
||||
enrichSession: (dto) => ({
|
||||
...dto,
|
||||
aiConversationCount: aiChatCounts?.get(dto.id) ?? 0,
|
||||
ownerExcluded: excludedSessionIds.has(dto.id),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => {
|
||||
const excludedSessionIds = new Set(preferences().load().ownerExcludedSessionIds)
|
||||
const session = sessionService.getAnalysisSession(adapter, request.params.id, {
|
||||
resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir),
|
||||
enrichSession: (dto) => ({ ...dto, ownerExcluded: excludedSessionIds.has(dto.id) }),
|
||||
})
|
||||
if (!session) {
|
||||
throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 })
|
||||
@@ -55,6 +60,7 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ success: false, error: 'File not found' })
|
||||
}
|
||||
ownerProfileService.clearOwnerExclusions(preferences(), [id])
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -67,6 +73,7 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
'/_web/sessions/:id/owner',
|
||||
async (request) => {
|
||||
sessionService.updateSessionOwnerId(adapter, request.params.id, request.body.ownerId ?? null)
|
||||
if (request.body.ownerId) ownerProfileService.clearOwnerExclusions(preferences(), [request.params.id])
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
@@ -90,9 +97,9 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
}
|
||||
)
|
||||
|
||||
// Suppress the owner prompt for this session (UI-only).
|
||||
server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/dismiss-prompt', async (request) => {
|
||||
ownerProfileService.dismissOwnerPrompt(preferences(), request.params.id)
|
||||
// Mark the session as not containing the current user.
|
||||
server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/exclude', async (request) => {
|
||||
ownerProfileService.excludeOwnerSession(preferences(), request.params.id)
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,25 @@ function readPreferencesFile(systemDir: string): any {
|
||||
return JSON.parse(readFileSync(join(systemDir, 'preferences.json'), 'utf-8'))
|
||||
}
|
||||
|
||||
test('migrates dismissed owner prompts to owner insight exclusions', () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
try {
|
||||
writeFileSync(
|
||||
join(systemDir, 'preferences.json'),
|
||||
JSON.stringify({ ownerPromptDismissedSessionIds: ['chat-1', 'chat-1', 42] })
|
||||
)
|
||||
|
||||
const loaded = new PreferencesManager(systemDir).load()
|
||||
const saved = readPreferencesFile(systemDir)
|
||||
|
||||
assert.deepEqual(loaded.ownerExcludedSessionIds, ['chat-1'])
|
||||
assert.deepEqual(saved.ownerExcludedSessionIds, ['chat-1'])
|
||||
assert.equal('ownerPromptDismissedSessionIds' in saved, false)
|
||||
} finally {
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('migrates old built-in desensitize rules out of preferences with a backup', () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
try {
|
||||
|
||||
@@ -62,7 +62,11 @@ const DEFAULTS: Preferences = {
|
||||
filterHistory: [],
|
||||
thinkingLevels: {},
|
||||
ownerProfilesByPlatform: {},
|
||||
ownerPromptDismissedSessionIds: [],
|
||||
ownerExcludedSessionIds: [],
|
||||
}
|
||||
|
||||
type LegacyPreferences = Partial<Preferences> & {
|
||||
ownerPromptDismissedSessionIds?: unknown
|
||||
}
|
||||
|
||||
export class PreferencesManager {
|
||||
@@ -79,10 +83,11 @@ export class PreferencesManager {
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const raw = fs.readFileSync(this.filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as Partial<Preferences>
|
||||
const migrated = this.migrateLegacyDesensitizeRules(parsed, raw)
|
||||
this.cache = this.mergeDefaults(migrated.preferences)
|
||||
if (migrated.changed) {
|
||||
const parsed = JSON.parse(raw) as LegacyPreferences
|
||||
const desensitizeMigration = this.migrateLegacyDesensitizeRules(parsed, raw)
|
||||
const ownerMigration = this.migrateLegacyOwnerExclusions(desensitizeMigration.preferences)
|
||||
this.cache = this.mergeDefaults(ownerMigration.preferences)
|
||||
if (desensitizeMigration.changed || ownerMigration.changed) {
|
||||
this.writePreferences(this.cache)
|
||||
}
|
||||
return this.cache
|
||||
@@ -140,7 +145,7 @@ export class PreferencesManager {
|
||||
filterHistory: partial.filterHistory ?? DEFAULTS.filterHistory,
|
||||
thinkingLevels: partial.thinkingLevels ?? DEFAULTS.thinkingLevels,
|
||||
ownerProfilesByPlatform: partial.ownerProfilesByPlatform ?? DEFAULTS.ownerProfilesByPlatform,
|
||||
ownerPromptDismissedSessionIds: partial.ownerPromptDismissedSessionIds ?? DEFAULTS.ownerPromptDismissedSessionIds,
|
||||
ownerExcludedSessionIds: this.normalizeStringArray(partial.ownerExcludedSessionIds),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +196,29 @@ export class PreferencesManager {
|
||||
return result
|
||||
}
|
||||
|
||||
private normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return [...new Set(value.filter((item): item is string => typeof item === 'string' && item.length > 0))]
|
||||
}
|
||||
|
||||
private migrateLegacyOwnerExclusions(partial: LegacyPreferences): {
|
||||
preferences: Partial<Preferences>
|
||||
changed: boolean
|
||||
} {
|
||||
if (partial.ownerPromptDismissedSessionIds === undefined) {
|
||||
return { preferences: partial, changed: false }
|
||||
}
|
||||
const { ownerPromptDismissedSessionIds, ...preferences } = partial
|
||||
return {
|
||||
preferences: {
|
||||
...preferences,
|
||||
ownerExcludedSessionIds:
|
||||
partial.ownerExcludedSessionIds ?? this.normalizeStringArray(ownerPromptDismissedSessionIds),
|
||||
},
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
private migrateLegacyDesensitizeRules(
|
||||
partial: Partial<Preferences>,
|
||||
rawContent: string
|
||||
|
||||
@@ -56,6 +56,14 @@ test('computes a snapshot across sessions and reuses versioned facts', (t) => {
|
||||
factsCacheDir,
|
||||
now: () => 2000,
|
||||
})
|
||||
const excluded = computeAnnualSummarySnapshot({
|
||||
adapter,
|
||||
signature: 'excluded-sig',
|
||||
range,
|
||||
factsCacheDir,
|
||||
excludedSessionIds: ['broken'],
|
||||
now: () => 3000,
|
||||
})
|
||||
|
||||
assert.equal(first.metrics.sentMessageCount, 1)
|
||||
assert.deepEqual(first.monthlyDirectContacts.slice(0, 2), [
|
||||
@@ -74,4 +82,12 @@ test('computes a snapshot across sessions and reuses versioned facts', (t) => {
|
||||
assert.equal(second.workerStats.cacheHits, 1)
|
||||
assert.equal(second.workerStats.cacheMisses, 1)
|
||||
assert.equal(second.computedAt, 2000)
|
||||
assert.deepEqual(excluded.coverage, {
|
||||
totalSessions: 1,
|
||||
analyzedSessions: 1,
|
||||
missingOwnerSessions: 0,
|
||||
unresolvedOwnerSessions: 0,
|
||||
failedSessions: 0,
|
||||
})
|
||||
assert.equal(excluded.workerStats.totalSessions, 1)
|
||||
})
|
||||
@@ -18,19 +18,21 @@ import {
|
||||
type AnnualSummaryComputeProgress,
|
||||
type AnnualSummarySnapshot,
|
||||
} from './types'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export interface ComputeAnnualSummarySnapshotOptions {
|
||||
adapter: SessionRuntimeAdapter
|
||||
signature: string
|
||||
range: AnnualSummaryRange
|
||||
factsCacheDir: string
|
||||
excludedSessionIds?: readonly string[]
|
||||
onProgress?: (progress: AnnualSummaryComputeProgress) => void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export function computeAnnualSummarySnapshot(options: ComputeAnnualSummarySnapshotOptions): AnnualSummarySnapshot {
|
||||
const startedAt = options.now?.() ?? Date.now()
|
||||
const sessionIds = options.adapter.listSessionIds()
|
||||
const sessionIds = listOwnerInsightSessionIds(options.adapter, options.excludedSessionIds)
|
||||
const facts: AnnualSummarySessionFacts[] = []
|
||||
let cacheHits = 0
|
||||
let cacheMisses = 0
|
||||
|
||||
@@ -41,7 +41,7 @@ function makeSnapshot(range: AnnualSummaryRange, signature: string, count = 3):
|
||||
}
|
||||
}
|
||||
|
||||
function createEnv(runner: AnnualSummaryComputeRunner) {
|
||||
function createEnv(runner: AnnualSummaryComputeRunner, getExcludedSessionIds?: () => readonly string[]) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-global-insight-service-'))
|
||||
const dbPath = path.join(dir, 'chat.db')
|
||||
fs.writeFileSync(dbPath, 'db')
|
||||
@@ -49,7 +49,13 @@ function createEnv(runner: AnnualSummaryComputeRunner) {
|
||||
listSessionIds: () => ['chat-1'],
|
||||
getDbPath: () => dbPath,
|
||||
} as unknown as SessionRuntimeAdapter
|
||||
const service = createGlobalInsightService({ adapter, userDataDir: dir, runner, now: () => Date.UTC(2026, 0, 2) })
|
||||
const service = createGlobalInsightService({
|
||||
adapter,
|
||||
userDataDir: dir,
|
||||
runner,
|
||||
getExcludedSessionIds,
|
||||
now: () => Date.UTC(2026, 0, 2),
|
||||
})
|
||||
return { dir, dbPath, adapter, service }
|
||||
}
|
||||
|
||||
@@ -81,6 +87,34 @@ test('starts one background task on a missing snapshot and then serves it fresh'
|
||||
assert.equal(fresh.metrics?.sentMessageCount, 3)
|
||||
})
|
||||
|
||||
test('invalidates the snapshot when owner insight exclusions change', async (t) => {
|
||||
let excludedSessionIds: string[] = []
|
||||
const runnerExclusions: string[][] = []
|
||||
const env = createEnv(
|
||||
async ({ signature, range, excludedSessionIds: exclusions }) => {
|
||||
runnerExclusions.push([...exclusions])
|
||||
return makeSnapshot(range, signature)
|
||||
},
|
||||
() => excludedSessionIds
|
||||
)
|
||||
t.after(async () => {
|
||||
await env.service.close()
|
||||
fs.rmSync(env.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
env.service.getAnnualSummary({ mode: 'year', year: 2026 })
|
||||
await flushTasks()
|
||||
assert.equal(env.service.getAnnualSummary({ mode: 'year', year: 2026 }).cache.status, 'fresh')
|
||||
|
||||
excludedSessionIds = ['chat-1']
|
||||
const stale = env.service.getAnnualSummary({ mode: 'year', year: 2026, acceptStale: true })
|
||||
assert.equal(stale.cache.status, 'stale')
|
||||
await flushTasks()
|
||||
|
||||
assert.deepEqual(runnerExclusions, [[], ['chat-1']])
|
||||
assert.equal(env.service.getAnnualSummary({ mode: 'year', year: 2026 }).cache.status, 'fresh')
|
||||
})
|
||||
|
||||
test('serves stale data while recomputing after the DB signature changes', async (t) => {
|
||||
let resolveRunner: ((snapshot: AnnualSummarySnapshot) => void) | undefined
|
||||
const runner: AnnualSummaryComputeRunner = ({ signature, range }) =>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { normalizeAnnualSummaryRange, toAnnualSummaryRangeKey, type AnnualSummaryRangeInput } from './time-range'
|
||||
import { createAnnualSummaryWorkerRunner, type AnnualSummaryComputeRunner } from './worker-runner'
|
||||
import type { AnnualSummarySnapshot } from './types'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export interface GlobalInsightServiceOptions extends AnnualSummaryRangeInput {
|
||||
acceptStale?: boolean
|
||||
@@ -29,6 +30,7 @@ export interface GlobalInsightServiceDeps {
|
||||
nativeBinding?: string
|
||||
workerEntryUrl?: string | URL
|
||||
runner?: AnnualSummaryComputeRunner
|
||||
getExcludedSessionIds?: () => readonly string[]
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
@@ -76,16 +78,18 @@ class DefaultGlobalInsightService implements GlobalInsightService {
|
||||
|
||||
getAnnualSummary(options: GlobalInsightServiceOptions = {}): AnnualSummaryResponse {
|
||||
const range = this.normalizeRange(options)
|
||||
const signature = buildAnnualSummarySignature(this.deps.adapter, range)
|
||||
const excludedSessionIds = this.getExcludedSessionIds()
|
||||
const signature = buildAnnualSummarySignature(this.deps.adapter, range, excludedSessionIds)
|
||||
const status = this.getCacheStatus(signature, range)
|
||||
if (this.shouldStartTask(options, status)) this.ensureTaskStarted(signature, range)
|
||||
if (this.shouldStartTask(options, status)) this.ensureTaskStarted(signature, range, excludedSessionIds)
|
||||
return this.toResponse(signature, range, options.acceptStale === true)
|
||||
}
|
||||
|
||||
startRecompute(options: GlobalInsightServiceOptions = {}): AnnualSummaryResponse {
|
||||
const range = this.normalizeRange(options)
|
||||
const signature = buildAnnualSummarySignature(this.deps.adapter, range)
|
||||
this.ensureTaskStarted(signature, range)
|
||||
const excludedSessionIds = this.getExcludedSessionIds()
|
||||
const signature = buildAnnualSummarySignature(this.deps.adapter, range, excludedSessionIds)
|
||||
this.ensureTaskStarted(signature, range, excludedSessionIds)
|
||||
return this.toResponse(signature, range, true)
|
||||
}
|
||||
|
||||
@@ -124,7 +128,7 @@ class DefaultGlobalInsightService implements GlobalInsightService {
|
||||
return this.task.status !== 'failed'
|
||||
}
|
||||
|
||||
private ensureTaskStarted(signature: string, range: AnnualSummaryRange): void {
|
||||
private ensureTaskStarted(signature: string, range: AnnualSummaryRange, excludedSessionIds: readonly string[]): void {
|
||||
if (this.inFlight) return
|
||||
const id = `annual_summary_${this.now()}_${Math.random().toString(36).slice(2)}`
|
||||
this.task = {
|
||||
@@ -133,12 +137,13 @@ class DefaultGlobalInsightService implements GlobalInsightService {
|
||||
startedAt: this.now(),
|
||||
finishedAt: null,
|
||||
processedSessions: 0,
|
||||
totalSessions: this.deps.adapter.listSessionIds().length,
|
||||
totalSessions: listOwnerInsightSessionIds(this.deps.adapter, excludedSessionIds).length,
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
const promise = this.runner({
|
||||
signature,
|
||||
range,
|
||||
excludedSessionIds,
|
||||
signal: abortController.signal,
|
||||
onProgress: (progress) => {
|
||||
if (this.task.id !== id || this.task.status !== 'running') return
|
||||
@@ -153,7 +158,7 @@ class DefaultGlobalInsightService implements GlobalInsightService {
|
||||
|
||||
private handleTaskSuccess(id: string, inputSignature: string, snapshot: AnnualSummarySnapshot): void {
|
||||
if (this.inFlight?.id !== id) return
|
||||
const latestSignature = buildAnnualSummarySignature(this.deps.adapter, snapshot.range)
|
||||
const latestSignature = buildAnnualSummarySignature(this.deps.adapter, snapshot.range, this.getExcludedSessionIds())
|
||||
const finishedAt = this.now()
|
||||
if (inputSignature !== latestSignature || snapshot.signature !== latestSignature) {
|
||||
this.inFlight = null
|
||||
@@ -240,6 +245,10 @@ class DefaultGlobalInsightService implements GlobalInsightService {
|
||||
private now(): number {
|
||||
return this.deps.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
private getExcludedSessionIds(): readonly string[] {
|
||||
return this.deps.getExcludedSessionIds?.() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
function requirePathProvider(deps: GlobalInsightServiceDeps): PathProvider {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SessionRuntimeAdapter } from '../adapters'
|
||||
|
||||
export function listOwnerInsightSessionIds(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
excludedSessionIds: readonly string[] = []
|
||||
): string[] {
|
||||
const excluded = new Set(excludedSessionIds)
|
||||
return adapter.listSessionIds().filter((sessionId) => !excluded.has(sessionId))
|
||||
}
|
||||
@@ -3,15 +3,20 @@ import { getDbFileVersion } from '../../cache/analytics-cache'
|
||||
import type { SessionRuntimeAdapter } from '../adapters'
|
||||
import { toAnnualSummaryLocalDate, toAnnualSummaryRangeKey } from './time-range'
|
||||
import { ANNUAL_SUMMARY_ALGORITHM_VERSION } from './types'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export function buildAnnualSummarySignature(adapter: SessionRuntimeAdapter, range: AnnualSummaryRange): string {
|
||||
export function buildAnnualSummarySignature(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
range: AnnualSummaryRange,
|
||||
excludedSessionIds: readonly string[] = []
|
||||
): string {
|
||||
const parts = [
|
||||
`algorithm:${ANNUAL_SUMMARY_ALGORITHM_VERSION}`,
|
||||
`range:${toAnnualSummaryRangeKey(range)}`,
|
||||
`start:${range.startTs}`,
|
||||
`day:${toAnnualSummaryLocalDate(range)}`,
|
||||
]
|
||||
for (const sessionId of [...adapter.listSessionIds()].sort()) {
|
||||
for (const sessionId of listOwnerInsightSessionIds(adapter, excludedSessionIds).sort()) {
|
||||
parts.push(`${sessionId}:${getDbFileVersion(adapter.getDbPath(sessionId))}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
|
||||
@@ -43,10 +43,21 @@ test('computes time investment across sessions and reuses versioned facts', (t)
|
||||
|
||||
const first = computeTimeInvestmentSnapshot({ adapter, signature: 'sig', range, factsCacheDir, now: () => 1000 })
|
||||
const second = computeTimeInvestmentSnapshot({ adapter, signature: 'sig', range, factsCacheDir, now: () => 2000 })
|
||||
const excluded = computeTimeInvestmentSnapshot({
|
||||
adapter,
|
||||
signature: 'excluded-sig',
|
||||
range,
|
||||
factsCacheDir,
|
||||
excludedSessionIds: ['chat-1'],
|
||||
now: () => 3000,
|
||||
})
|
||||
|
||||
assert.equal(first.metrics.estimatedSeconds, 180)
|
||||
assert.equal(first.sessionRanking[0].sessionId, 'chat-1')
|
||||
assert.equal(first.workerStats.cacheMisses, 1)
|
||||
assert.equal(second.workerStats.cacheHits, 1)
|
||||
assert.equal(second.computedAt, 2000)
|
||||
assert.equal(excluded.metrics.estimatedSeconds, 0)
|
||||
assert.equal(excluded.coverage.totalSessions, 0)
|
||||
assert.equal(excluded.workerStats.totalSessions, 0)
|
||||
})
|
||||
@@ -14,19 +14,21 @@ import {
|
||||
} from './time-investment-facts-cache'
|
||||
import { TIME_INVESTMENT_ALGORITHM_VERSION, type TimeInvestmentSnapshot } from './time-investment-types'
|
||||
import type { AnnualSummaryComputeProgress } from './types'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export interface ComputeTimeInvestmentSnapshotOptions {
|
||||
adapter: SessionRuntimeAdapter
|
||||
signature: string
|
||||
range: AnnualSummaryRange
|
||||
factsCacheDir: string
|
||||
excludedSessionIds?: readonly string[]
|
||||
onProgress?: (progress: AnnualSummaryComputeProgress) => void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export function computeTimeInvestmentSnapshot(options: ComputeTimeInvestmentSnapshotOptions): TimeInvestmentSnapshot {
|
||||
const startedAt = options.now?.() ?? Date.now()
|
||||
const sessionIds = options.adapter.listSessionIds()
|
||||
const sessionIds = listOwnerInsightSessionIds(options.adapter, options.excludedSessionIds)
|
||||
const facts: TimeInvestmentSessionFacts[] = []
|
||||
let cacheHits = 0
|
||||
let cacheMisses = 0
|
||||
|
||||
@@ -20,11 +20,20 @@ test('starts one time investment task and exposes its persisted snapshot', async
|
||||
getDbPath: () => dbPath,
|
||||
} as unknown as SessionRuntimeAdapter
|
||||
let runnerCalls = 0
|
||||
const runner: TimeInvestmentComputeRunner = async ({ signature, range: normalizedRange }) => {
|
||||
let excludedSessionIds: string[] = []
|
||||
const runnerExclusions: string[][] = []
|
||||
const runner: TimeInvestmentComputeRunner = async ({ signature, range: normalizedRange, excludedSessionIds }) => {
|
||||
runnerCalls++
|
||||
runnerExclusions.push([...excludedSessionIds])
|
||||
return snapshot(normalizedRange, signature)
|
||||
}
|
||||
const service = createTimeInvestmentService({ adapter, userDataDir: dir, runner, now: () => now })
|
||||
const service = createTimeInvestmentService({
|
||||
adapter,
|
||||
userDataDir: dir,
|
||||
runner,
|
||||
getExcludedSessionIds: () => excludedSessionIds,
|
||||
now: () => now,
|
||||
})
|
||||
|
||||
const first = service.getTimeInvestment({ mode: 'year', year: 2026 })
|
||||
const duplicate = service.getTimeInvestment({ mode: 'year', year: 2026 })
|
||||
@@ -38,6 +47,14 @@ test('starts one time investment task and exposes its persisted snapshot', async
|
||||
assert.equal(completed.cache.status, 'fresh')
|
||||
assert.equal(completed.metrics?.estimatedSeconds, 300)
|
||||
assert.equal(completed.task.status, 'succeeded')
|
||||
|
||||
excludedSessionIds = ['chat-1']
|
||||
const stale = service.getTimeInvestment({ mode: 'year', year: 2026, acceptStale: true })
|
||||
assert.equal(stale.cache.status, 'stale')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(runnerCalls, 2)
|
||||
assert.deepEqual(runnerExclusions, [[], ['chat-1']])
|
||||
assert.equal(service.getTimeInvestment({ mode: 'year', year: 2026 }).cache.status, 'fresh')
|
||||
await service.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './time-investment-snapshot'
|
||||
import type { TimeInvestmentSnapshot } from './time-investment-types'
|
||||
import { createTimeInvestmentWorkerRunner, type TimeInvestmentComputeRunner } from './worker-runner'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export interface TimeInvestmentServiceOptions extends AnnualSummaryRangeInput {
|
||||
acceptStale?: boolean
|
||||
@@ -28,6 +29,7 @@ export interface TimeInvestmentServiceDeps {
|
||||
nativeBinding?: string
|
||||
workerEntryUrl?: string | URL
|
||||
runner?: TimeInvestmentComputeRunner
|
||||
getExcludedSessionIds?: () => readonly string[]
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
@@ -71,16 +73,18 @@ class DefaultTimeInvestmentService implements TimeInvestmentService {
|
||||
|
||||
getTimeInvestment(options: TimeInvestmentServiceOptions = {}): TimeInvestmentResponse {
|
||||
const range = this.normalizeRange(options)
|
||||
const signature = buildTimeInvestmentSignature(this.deps.adapter, range)
|
||||
const excludedSessionIds = this.getExcludedSessionIds()
|
||||
const signature = buildTimeInvestmentSignature(this.deps.adapter, range, excludedSessionIds)
|
||||
const status = this.getCacheStatus(signature, range)
|
||||
if (this.shouldStartTask(status)) this.ensureTaskStarted(signature, range)
|
||||
if (this.shouldStartTask(status)) this.ensureTaskStarted(signature, range, excludedSessionIds)
|
||||
return this.toResponse(signature, range, options.acceptStale === true)
|
||||
}
|
||||
|
||||
startRecompute(options: TimeInvestmentServiceOptions = {}): TimeInvestmentResponse {
|
||||
const range = this.normalizeRange(options)
|
||||
const signature = buildTimeInvestmentSignature(this.deps.adapter, range)
|
||||
this.ensureTaskStarted(signature, range)
|
||||
const excludedSessionIds = this.getExcludedSessionIds()
|
||||
const signature = buildTimeInvestmentSignature(this.deps.adapter, range, excludedSessionIds)
|
||||
this.ensureTaskStarted(signature, range, excludedSessionIds)
|
||||
return this.toResponse(signature, range, true)
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ class DefaultTimeInvestmentService implements TimeInvestmentService {
|
||||
return normalizeAnnualSummaryRange(input, new Date(this.now()))
|
||||
}
|
||||
|
||||
private ensureTaskStarted(signature: string, range: AnnualSummaryRange): void {
|
||||
private ensureTaskStarted(signature: string, range: AnnualSummaryRange, excludedSessionIds: readonly string[]): void {
|
||||
if (this.inFlight) return
|
||||
const id = `time_investment_${this.now()}_${Math.random().toString(36).slice(2)}`
|
||||
this.task = {
|
||||
@@ -115,12 +119,13 @@ class DefaultTimeInvestmentService implements TimeInvestmentService {
|
||||
startedAt: this.now(),
|
||||
finishedAt: null,
|
||||
processedSessions: 0,
|
||||
totalSessions: this.deps.adapter.listSessionIds().length,
|
||||
totalSessions: listOwnerInsightSessionIds(this.deps.adapter, excludedSessionIds).length,
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
const promise = this.runner({
|
||||
signature,
|
||||
range,
|
||||
excludedSessionIds,
|
||||
signal: abortController.signal,
|
||||
onProgress: (progress) => {
|
||||
if (this.task.id !== id || this.task.status !== 'running') return
|
||||
@@ -135,7 +140,11 @@ class DefaultTimeInvestmentService implements TimeInvestmentService {
|
||||
|
||||
private handleTaskSuccess(id: string, inputSignature: string, snapshot: TimeInvestmentSnapshot): void {
|
||||
if (this.inFlight?.id !== id) return
|
||||
const latestSignature = buildTimeInvestmentSignature(this.deps.adapter, snapshot.range)
|
||||
const latestSignature = buildTimeInvestmentSignature(
|
||||
this.deps.adapter,
|
||||
snapshot.range,
|
||||
this.getExcludedSessionIds()
|
||||
)
|
||||
const finishedAt = this.now()
|
||||
if (inputSignature !== latestSignature || snapshot.signature !== latestSignature) {
|
||||
this.inFlight = null
|
||||
@@ -221,6 +230,10 @@ class DefaultTimeInvestmentService implements TimeInvestmentService {
|
||||
private now(): number {
|
||||
return this.deps.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
private getExcludedSessionIds(): readonly string[] {
|
||||
return this.deps.getExcludedSessionIds?.() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
function requirePathProvider(deps: TimeInvestmentServiceDeps): PathProvider {
|
||||
|
||||
@@ -3,15 +3,20 @@ import { getDbFileVersion } from '../../cache/analytics-cache'
|
||||
import type { SessionRuntimeAdapter } from '../adapters'
|
||||
import { toAnnualSummaryLocalDate, toAnnualSummaryRangeKey } from './time-range'
|
||||
import { TIME_INVESTMENT_ALGORITHM_VERSION } from './time-investment-types'
|
||||
import { listOwnerInsightSessionIds } from './session-scope'
|
||||
|
||||
export function buildTimeInvestmentSignature(adapter: SessionRuntimeAdapter, range: AnnualSummaryRange): string {
|
||||
export function buildTimeInvestmentSignature(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
range: AnnualSummaryRange,
|
||||
excludedSessionIds: readonly string[] = []
|
||||
): string {
|
||||
const parts = [
|
||||
`algorithm:${TIME_INVESTMENT_ALGORITHM_VERSION}`,
|
||||
`range:${toAnnualSummaryRangeKey(range)}`,
|
||||
`start:${range.startTs}`,
|
||||
`day:${toAnnualSummaryLocalDate(range)}`,
|
||||
]
|
||||
for (const sessionId of [...adapter.listSessionIds()].sort()) {
|
||||
for (const sessionId of listOwnerInsightSessionIds(adapter, excludedSessionIds).sort()) {
|
||||
parts.push(`${sessionId}:${getDbFileVersion(adapter.getDbPath(sessionId))}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
|
||||
@@ -17,6 +17,7 @@ interface StartupOptions {
|
||||
nativeBinding?: string
|
||||
signature: string
|
||||
range: AnnualSummaryRange
|
||||
excludedSessionIds: string[]
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -35,6 +36,7 @@ async function main(): Promise<void> {
|
||||
adapter,
|
||||
signature: options.signature,
|
||||
range: options.range,
|
||||
excludedSessionIds: options.excludedSessionIds,
|
||||
factsCacheDir: getTimeInvestmentFactsCacheDir(pathProvider.getUserDataDir()),
|
||||
onProgress: (progress) => parentPort?.postMessage({ type: 'progress', progress }),
|
||||
}
|
||||
@@ -46,6 +48,7 @@ async function main(): Promise<void> {
|
||||
adapter,
|
||||
signature: options.signature,
|
||||
range: options.range,
|
||||
excludedSessionIds: options.excludedSessionIds,
|
||||
factsCacheDir: getGlobalInsightFactsCacheDir(pathProvider.getUserDataDir()),
|
||||
onProgress: (progress) => parentPort?.postMessage({ type: 'progress', progress }),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type EntryExists = (url: URL) => boolean
|
||||
export interface AnnualSummaryRunnerOptions {
|
||||
signature: string
|
||||
range: AnnualSummaryRange
|
||||
excludedSessionIds: readonly string[]
|
||||
onProgress: (progress: AnnualSummaryComputeProgress) => void
|
||||
signal: AbortSignal
|
||||
}
|
||||
@@ -46,7 +47,7 @@ export function resolveDefaultAnnualSummaryWorkerEntryUrl(
|
||||
}
|
||||
|
||||
export function createAnnualSummaryWorkerRunner(options: AnnualSummaryWorkerRunnerOptions): AnnualSummaryComputeRunner {
|
||||
return ({ signature, range, signal, onProgress }) =>
|
||||
return ({ signature, range, excludedSessionIds, signal, onProgress }) =>
|
||||
new Promise<AnnualSummarySnapshot>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error('annual summary worker aborted'))
|
||||
@@ -59,6 +60,7 @@ export function createAnnualSummaryWorkerRunner(options: AnnualSummaryWorkerRunn
|
||||
nativeBinding: options.nativeBinding,
|
||||
signature,
|
||||
range,
|
||||
excludedSessionIds,
|
||||
},
|
||||
options.workerEntryUrl
|
||||
)
|
||||
@@ -107,7 +109,7 @@ export function createAnnualSummaryWorkerRunner(options: AnnualSummaryWorkerRunn
|
||||
export function createTimeInvestmentWorkerRunner(
|
||||
options: AnnualSummaryWorkerRunnerOptions
|
||||
): TimeInvestmentComputeRunner {
|
||||
return ({ signature, range, signal, onProgress }) =>
|
||||
return ({ signature, range, excludedSessionIds, signal, onProgress }) =>
|
||||
new Promise<TimeInvestmentSnapshot>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error('time investment worker aborted'))
|
||||
@@ -121,6 +123,7 @@ export function createTimeInvestmentWorkerRunner(
|
||||
nativeBinding: options.nativeBinding,
|
||||
signature,
|
||||
range,
|
||||
excludedSessionIds,
|
||||
},
|
||||
options.workerEntryUrl
|
||||
)
|
||||
|
||||
@@ -28,7 +28,8 @@ export type { MembersPaginatedDTO } from './member-service'
|
||||
export {
|
||||
tryApplyOwnerProfile,
|
||||
setOwnerAndApplyProfile,
|
||||
dismissOwnerPrompt,
|
||||
excludeOwnerSession,
|
||||
clearOwnerExclusions,
|
||||
clearSessionOwner,
|
||||
} from './owner-profile-service'
|
||||
export type {
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { SessionRuntimeAdapter } from './adapters'
|
||||
import {
|
||||
tryApplyOwnerProfile,
|
||||
setOwnerAndApplyProfile,
|
||||
dismissOwnerPrompt,
|
||||
excludeOwnerSession,
|
||||
clearSessionOwner,
|
||||
} from './owner-profile-service'
|
||||
|
||||
@@ -215,7 +215,7 @@ test('tryApplyOwnerProfile applies stored profile and reports reasons', (t) => {
|
||||
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), {
|
||||
applied: false,
|
||||
reason: 'no_profile',
|
||||
dismissed: false,
|
||||
excluded: false,
|
||||
})
|
||||
|
||||
setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'Alice')
|
||||
@@ -225,53 +225,59 @@ test('tryApplyOwnerProfile applies stored profile and reports reasons', (t) => {
|
||||
applied: false,
|
||||
ownerId: 'Alice',
|
||||
reason: 'already_set',
|
||||
dismissed: false,
|
||||
excluded: false,
|
||||
})
|
||||
|
||||
// New session imported later: profile applies on demand
|
||||
env.seed({ id: 'later', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Carol' }] })
|
||||
const applied = tryApplyOwnerProfile(env.adapter, env.preferences, 'later')
|
||||
assert.deepEqual(applied, { applied: true, ownerId: 'Alice', dismissed: false })
|
||||
assert.deepEqual(applied, { applied: true, ownerId: 'Alice', excluded: false })
|
||||
assert.equal(env.ownerOf('later'), 'Alice')
|
||||
|
||||
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'no-profile'), {
|
||||
applied: false,
|
||||
reason: 'no_profile',
|
||||
dismissed: false,
|
||||
excluded: false,
|
||||
})
|
||||
assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'missing').reason, 'missing_session')
|
||||
})
|
||||
|
||||
test('dismissOwnerPrompt persists and is cleared by manual owner selection', (t) => {
|
||||
test('excludeOwnerSession blocks automatic matching and is cleared by manual owner selection', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
|
||||
|
||||
dismissOwnerPrompt(env.preferences, 's1')
|
||||
dismissOwnerPrompt(env.preferences, 's1')
|
||||
excludeOwnerSession(env.preferences, 's1')
|
||||
excludeOwnerSession(env.preferences, 's1')
|
||||
env.preferences.invalidateCache()
|
||||
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, ['s1'])
|
||||
assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 's1').dismissed, true)
|
||||
assert.deepEqual(env.preferences.load().ownerExcludedSessionIds, ['s1'])
|
||||
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 's1'), {
|
||||
applied: false,
|
||||
reason: 'excluded',
|
||||
excluded: true,
|
||||
})
|
||||
assert.equal(env.ownerOf('s1'), null)
|
||||
|
||||
setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice')
|
||||
env.preferences.invalidateCache()
|
||||
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, [])
|
||||
assert.deepEqual(env.preferences.load().ownerExcludedSessionIds, [])
|
||||
})
|
||||
|
||||
test('batch apply removes auto-filled sessions from the dismissed list', (t) => {
|
||||
test('batch apply skips sessions confirmed not to contain the owner', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
|
||||
env.seed({ id: 'other', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] })
|
||||
|
||||
dismissOwnerPrompt(env.preferences, 'other')
|
||||
excludeOwnerSession(env.preferences, 'other')
|
||||
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice')
|
||||
assert.deepEqual(result.updatedSessionIds, ['other'])
|
||||
assert.deepEqual(result.updatedSessionIds, [])
|
||||
assert.equal(env.ownerOf('other'), null)
|
||||
|
||||
env.preferences.invalidateCache()
|
||||
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, [])
|
||||
assert.deepEqual(env.preferences.load().ownerExcludedSessionIds, ['other'])
|
||||
})
|
||||
|
||||
test('clearSessionOwner clears the session owner but keeps the platform profile', (t) => {
|
||||
|
||||
@@ -32,8 +32,8 @@ function memberDisplayName(member: MemberWithAliases): string {
|
||||
return member.groupNickname || member.accountName || member.platformId
|
||||
}
|
||||
|
||||
function isDismissed(preferences: PreferencesManager, sessionId: string): boolean {
|
||||
return preferences.load().ownerPromptDismissedSessionIds.includes(sessionId)
|
||||
function isExcluded(preferences: PreferencesManager, sessionId: string): boolean {
|
||||
return preferences.load().ownerExcludedSessionIds.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,32 +46,34 @@ export function tryApplyOwnerProfile(
|
||||
preferences: PreferencesManager,
|
||||
sessionId: string
|
||||
): ApplyOwnerProfileResult {
|
||||
const dismissed = isDismissed(preferences, sessionId)
|
||||
|
||||
const db = adapter.openReadonly(sessionId)
|
||||
if (!db || !isChatSessionDb(db)) {
|
||||
return { applied: false, reason: 'missing_session', dismissed }
|
||||
return { applied: false, reason: 'missing_session', excluded: false }
|
||||
}
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) {
|
||||
return { applied: false, reason: 'missing_session', dismissed }
|
||||
return { applied: false, reason: 'missing_session', excluded: false }
|
||||
}
|
||||
if (meta.ownerId) {
|
||||
return { applied: false, ownerId: meta.ownerId, reason: 'already_set', dismissed }
|
||||
clearOwnerExclusions(preferences, [sessionId])
|
||||
return { applied: false, ownerId: meta.ownerId, reason: 'already_set', excluded: false }
|
||||
}
|
||||
if (isExcluded(preferences, sessionId)) {
|
||||
return { applied: false, reason: 'excluded', excluded: true }
|
||||
}
|
||||
|
||||
const profile = preferences.load().ownerProfilesByPlatform[meta.platform]
|
||||
if (!profile) {
|
||||
return { applied: false, reason: 'no_profile', dismissed }
|
||||
return { applied: false, reason: 'no_profile', excluded: false }
|
||||
}
|
||||
|
||||
const result = matchOwnerProfile(meta.platform, profile, getMembersWithAliases(db))
|
||||
if (result.type === 'exact' || result.type === 'name') {
|
||||
const writable = adapter.ensureWritable(sessionId)
|
||||
coreUpdateSessionOwnerId(writable, result.platformId)
|
||||
return { applied: true, ownerId: result.platformId, dismissed }
|
||||
return { applied: true, ownerId: result.platformId, excluded: false }
|
||||
}
|
||||
return { applied: false, reason: result.type === 'ambiguous' ? 'ambiguous' : 'no_match', dismissed }
|
||||
return { applied: false, reason: result.type === 'ambiguous' ? 'ambiguous' : 'no_match', excluded: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,17 +115,23 @@ export function setOwnerAndApplyProfile(
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const updatedSessions = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId)
|
||||
const updatedSessions = applyProfileToOtherSessions(
|
||||
adapter,
|
||||
meta.platform,
|
||||
profile,
|
||||
sessionId,
|
||||
new Set(prefs.ownerExcludedSessionIds)
|
||||
)
|
||||
const updatedSessionIds = updatedSessions.map((s) => s.id)
|
||||
const updatedSessionOwnerIds: Record<string, string> = {}
|
||||
for (const s of updatedSessions) {
|
||||
updatedSessionOwnerIds[s.id] = s.ownerId
|
||||
}
|
||||
|
||||
const noLongerDismissed = new Set([sessionId, ...updatedSessionIds])
|
||||
const noLongerExcluded = new Set([sessionId, ...updatedSessionIds])
|
||||
preferences.save({
|
||||
ownerProfilesByPlatform: { ...prefs.ownerProfilesByPlatform, [meta.platform]: profile },
|
||||
ownerPromptDismissedSessionIds: prefs.ownerPromptDismissedSessionIds.filter((id) => !noLongerDismissed.has(id)),
|
||||
ownerExcludedSessionIds: prefs.ownerExcludedSessionIds.filter((id) => !noLongerExcluded.has(id)),
|
||||
})
|
||||
|
||||
return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds, updatedSessionOwnerIds }
|
||||
@@ -139,11 +147,12 @@ function applyProfileToOtherSessions(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
platform: string,
|
||||
profile: OwnerProfile,
|
||||
excludeSessionId: string
|
||||
excludeSessionId: string,
|
||||
ownerExcludedSessionIds: ReadonlySet<string>
|
||||
): Array<{ id: string; ownerId: string }> {
|
||||
const updated: Array<{ id: string; ownerId: string }> = []
|
||||
for (const id of adapter.listSessionIds()) {
|
||||
if (id === excludeSessionId) continue
|
||||
if (id === excludeSessionId || ownerExcludedSessionIds.has(id)) continue
|
||||
try {
|
||||
const db = adapter.openReadonly(id)
|
||||
if (!db || !isChatSessionDb(db)) continue
|
||||
@@ -163,17 +172,26 @@ function applyProfileToOtherSessions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the owner prompt for one session. UI-only: does not imply owner
|
||||
* knowledge and does not block later automatic profile application.
|
||||
* Mark one session as not containing the user. The session is excluded from
|
||||
* owner-dependent insights and automatic owner profile application.
|
||||
*/
|
||||
export function dismissOwnerPrompt(preferences: PreferencesManager, sessionId: string): void {
|
||||
export function excludeOwnerSession(preferences: PreferencesManager, sessionId: string): void {
|
||||
const prefs = preferences.load()
|
||||
if (prefs.ownerPromptDismissedSessionIds.includes(sessionId)) return
|
||||
if (prefs.ownerExcludedSessionIds.includes(sessionId)) return
|
||||
preferences.save({
|
||||
ownerPromptDismissedSessionIds: [...prefs.ownerPromptDismissedSessionIds, sessionId],
|
||||
ownerExcludedSessionIds: [...prefs.ownerExcludedSessionIds, sessionId],
|
||||
})
|
||||
}
|
||||
|
||||
export function clearOwnerExclusions(preferences: PreferencesManager, sessionIds: Iterable<string>): void {
|
||||
const excluded = new Set(sessionIds)
|
||||
if (excluded.size === 0) return
|
||||
const prefs = preferences.load()
|
||||
const next = prefs.ownerExcludedSessionIds.filter((id) => !excluded.has(id))
|
||||
if (next.length === prefs.ownerExcludedSessionIds.length) return
|
||||
preferences.save({ ownerExcludedSessionIds: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the owner of one session only. The platform profile is kept and may
|
||||
* re-apply automatically later if it still uniquely matches.
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AnalysisSessionDTO extends CoreSessionInfo {
|
||||
aiConversationCount: number
|
||||
ownerName: string | null
|
||||
ownerStatus: 'resolved' | 'missing' | 'unresolved'
|
||||
ownerExcluded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,7 @@ function buildSession(
|
||||
aiConversationCount: 0,
|
||||
ownerName: ownerMember?.displayName ?? null,
|
||||
ownerStatus: !meta.ownerId ? 'missing' : ownerMember ? 'resolved' : 'unresolved',
|
||||
ownerExcluded: false,
|
||||
}
|
||||
if (options?.enrichSession) {
|
||||
dto = options.enrichSession(dto)
|
||||
|
||||
@@ -284,14 +284,20 @@ export interface OwnerProfile {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ApplyOwnerProfileReason = 'no_profile' | 'no_match' | 'ambiguous' | 'already_set' | 'missing_session'
|
||||
export type ApplyOwnerProfileReason =
|
||||
| 'no_profile'
|
||||
| 'no_match'
|
||||
| 'ambiguous'
|
||||
| 'already_set'
|
||||
| 'missing_session'
|
||||
| 'excluded'
|
||||
|
||||
export interface ApplyOwnerProfileResult {
|
||||
applied: boolean
|
||||
ownerId?: string
|
||||
reason?: ApplyOwnerProfileReason
|
||||
/** Whether the user chose "do not remind me" for this session (UI hint only). */
|
||||
dismissed: boolean
|
||||
/** Whether the user confirmed that their own messages are absent from this session. */
|
||||
excluded: boolean
|
||||
}
|
||||
|
||||
export interface SetOwnerAndApplyProfileResult {
|
||||
@@ -802,8 +808,8 @@ export interface Preferences {
|
||||
thinkingLevels: Record<string, string>
|
||||
/** Platform-level owner identity, keyed by platform (e.g. 'whatsapp'). */
|
||||
ownerProfilesByPlatform: Record<string, OwnerProfile>
|
||||
/** Sessions where the user chose "do not remind me"; suppresses the owner prompt UI only. */
|
||||
ownerPromptDismissedSessionIds: string[]
|
||||
/** Sessions confirmed not to contain the user; excluded from owner-dependent insights. */
|
||||
ownerExcludedSessionIds: string[]
|
||||
}
|
||||
|
||||
export interface UiConfig {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 两种使用方式:
|
||||
* 1. 会话页挂载(autoCheck):会话无 owner 时先尝试用平台 owner profile 自动补全,
|
||||
* 补全失败且未被"本会话不再提醒"时自动弹出
|
||||
* 补全失败且未确认“当前对话中没有我”时自动弹出
|
||||
* 2. 成员管理入口手动打开(v-model)
|
||||
*
|
||||
* 保存时会更新平台级 owner profile,并自动应用到同平台其他未设置 owner 的会话。
|
||||
@@ -92,7 +92,7 @@ async function checkAndAutoOpen() {
|
||||
try {
|
||||
const result = await sessionStore.tryApplyOwnerProfile(props.sessionId)
|
||||
if (result.applied || result.reason === 'already_set' || result.reason === 'missing_session') return
|
||||
if (result.dismissed) return
|
||||
if (result.excluded) return
|
||||
isOpen.value = true
|
||||
} catch (error) {
|
||||
console.error('检查会话 owner 失败:', error)
|
||||
@@ -139,11 +139,11 @@ async function clearOwner() {
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissForever() {
|
||||
async function excludeCurrentSession() {
|
||||
if (!props.sessionId) return
|
||||
isDismissing.value = true
|
||||
try {
|
||||
await sessionStore.dismissOwnerPrompt(props.sessionId)
|
||||
await sessionStore.excludeOwnerSession(props.sessionId)
|
||||
isOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('忽略 owner 提醒失败:', error)
|
||||
@@ -284,9 +284,9 @@ onMounted(() => {
|
||||
color="primary"
|
||||
size="sm"
|
||||
:loading="isDismissing"
|
||||
@click="dismissForever"
|
||||
@click="excludeCurrentSession"
|
||||
>
|
||||
{{ t('members.ownerPrompt.dontRemind') }}
|
||||
{{ t('members.ownerPrompt.notInConversation') }}
|
||||
</UButton>
|
||||
<span v-else />
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -33,7 +33,11 @@ const { sessions } = storeToRefs(sessionStore)
|
||||
|
||||
// 搜索关键词
|
||||
const searchQuery = ref('')
|
||||
const ownerIssueCount = computed(() => sessions.value.filter((session) => session.ownerStatus !== 'resolved').length)
|
||||
function isOwnerIssue(session: AnalysisSession): boolean {
|
||||
return session.ownerStatus !== 'resolved' && !session.ownerExcluded
|
||||
}
|
||||
|
||||
const ownerIssueCount = computed(() => sessions.value.filter(isOwnerIssue).length)
|
||||
|
||||
// 过滤后的会话列表
|
||||
const filteredSessions = computed(() => {
|
||||
@@ -169,7 +173,8 @@ const sortedSessions = computed(() => {
|
||||
if (field === 'owner') {
|
||||
const ownerValue = (session: AnalysisSession) => {
|
||||
if (session.ownerStatus === 'resolved') return `0:${session.ownerName || session.ownerId || ''}`
|
||||
return session.ownerStatus === 'unresolved' ? '1:' : '2:'
|
||||
if (session.ownerExcluded) return '1:'
|
||||
return session.ownerStatus === 'unresolved' ? '2:' : '3:'
|
||||
}
|
||||
return ownerValue(a).localeCompare(ownerValue(b), locale.value) * multiplier
|
||||
}
|
||||
@@ -246,7 +251,7 @@ const ownerSession = computed(() => sessions.value.find((session) => session.id
|
||||
function openOwnerModal(session: AnalysisSession, event: Event) {
|
||||
event.stopPropagation()
|
||||
ownerSessionId.value = session.id
|
||||
continueOwnerSetup.value = session.ownerStatus !== 'resolved'
|
||||
continueOwnerSetup.value = isOwnerIssue(session)
|
||||
showOwnerModal.value = true
|
||||
}
|
||||
|
||||
@@ -255,7 +260,7 @@ async function refreshAfterOwnerChange() {
|
||||
if (!continueOwnerSetup.value) return
|
||||
|
||||
await nextTick()
|
||||
const nextSession = sortedSessions.value.find((session) => session.ownerStatus !== 'resolved')
|
||||
const nextSession = sortedSessions.value.find(isOwnerIssue)
|
||||
if (nextSession) {
|
||||
ownerSessionId.value = nextSession.id
|
||||
showOwnerModal.value = true
|
||||
@@ -724,7 +729,7 @@ onMounted(() => {
|
||||
type="button"
|
||||
class="flex w-28 min-w-0 items-center gap-1.5 text-left text-xs transition-colors hover:text-primary-600 dark:hover:text-primary-400"
|
||||
:class="
|
||||
session.ownerStatus === 'resolved'
|
||||
session.ownerStatus === 'resolved' || session.ownerExcluded
|
||||
? 'text-gray-600 dark:text-gray-300'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
"
|
||||
@@ -733,7 +738,11 @@ onMounted(() => {
|
||||
>
|
||||
<UIcon
|
||||
:name="
|
||||
session.ownerStatus === 'resolved' ? 'i-heroicons-user-circle' : 'i-heroicons-exclamation-circle'
|
||||
session.ownerStatus === 'resolved'
|
||||
? 'i-heroicons-user-circle'
|
||||
: session.ownerExcluded
|
||||
? 'i-heroicons-user-minus'
|
||||
: 'i-heroicons-exclamation-circle'
|
||||
"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"noMatch": "No matching members found",
|
||||
"empty": "No member data",
|
||||
"later": "Later",
|
||||
"dontRemind": "Don't remind me for this chat",
|
||||
"notInConversation": "I'm not in this conversation",
|
||||
"save": "Save",
|
||||
"clear": "Clear current selection",
|
||||
"saved": "Your identity has been set",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"empty": "メンバーデータがありません",
|
||||
"loadError": "メンバーデータの読み込みに失敗しました。再試行してください",
|
||||
"later": "後で",
|
||||
"dontRemind": "このチャットでは再通知しない",
|
||||
"notInConversation": "この会話に私はいません",
|
||||
"save": "保存",
|
||||
"clear": "現在の選択をクリア",
|
||||
"saved": "あなたの身元を設定しました",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"empty": "暂无成员数据",
|
||||
"loadError": "成员数据加载失败,请重试",
|
||||
"later": "稍后",
|
||||
"dontRemind": "本会话不再提醒",
|
||||
"notInConversation": "当前对话中没有我",
|
||||
"save": "保存",
|
||||
"clear": "清除当前选择",
|
||||
"saved": "已设置你的身份",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"empty": "暫無成員資料",
|
||||
"loadError": "成員資料載入失敗,請重試",
|
||||
"later": "稍後",
|
||||
"dontRemind": "此對話不再提醒",
|
||||
"notInConversation": "目前對話中沒有我",
|
||||
"save": "儲存",
|
||||
"clear": "清除目前選擇",
|
||||
"saved": "已設定你的身分",
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('BrowserDataAdapter', () => {
|
||||
ownerId: null,
|
||||
ownerName: null,
|
||||
ownerStatus: 'missing',
|
||||
ownerExcluded: false,
|
||||
memberAvatar: null,
|
||||
lastMessageTs: 2,
|
||||
summaryCount: 0,
|
||||
|
||||
@@ -261,6 +261,7 @@ function mapSession(item: BrowserSessionCatalogItem): AnalysisSession {
|
||||
ownerId: item.ownerId,
|
||||
ownerName: null,
|
||||
ownerStatus: item.ownerId ? 'unresolved' : 'missing',
|
||||
ownerExcluded: false,
|
||||
memberAvatar: null,
|
||||
lastMessageTs: item.lastMessageTs,
|
||||
summaryCount: 0,
|
||||
|
||||
@@ -124,8 +124,8 @@ export class FetchDataAdapter implements DataAdapter {
|
||||
return post(`/sessions/${sessionId}/owner/select`, { ownerPlatformId })
|
||||
}
|
||||
|
||||
async dismissOwnerPrompt(sessionId: string): Promise<boolean> {
|
||||
const result = await post<{ success: boolean }>(`/sessions/${sessionId}/owner/dismiss-prompt`, {})
|
||||
async excludeOwnerSession(sessionId: string): Promise<boolean> {
|
||||
const result = await post<{ success: boolean }>(`/sessions/${sessionId}/owner/exclude`, {})
|
||||
return result.success
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ export interface DataAdapter {
|
||||
updateSessionOwnerId(sessionId: string, ownerId: string | null): Promise<boolean>
|
||||
tryApplyOwnerProfile(sessionId: string): Promise<ApplyOwnerProfileResult>
|
||||
setOwnerAndApplyProfile(sessionId: string, ownerPlatformId: string): Promise<SetOwnerAndApplyProfileResult>
|
||||
dismissOwnerPrompt(sessionId: string): Promise<boolean>
|
||||
excludeOwnerSession(sessionId: string): Promise<boolean>
|
||||
|
||||
// ==================== 联系人 ====================
|
||||
|
||||
|
||||
+12
-4
@@ -807,6 +807,7 @@ export const useSessionStore = defineStore(
|
||||
session.ownerId = ownerId
|
||||
session.ownerName = null
|
||||
session.ownerStatus = ownerId ? 'unresolved' : 'missing'
|
||||
if (ownerId) session.ownerExcluded = false
|
||||
}
|
||||
}
|
||||
return success
|
||||
@@ -828,6 +829,7 @@ export const useSessionStore = defineStore(
|
||||
session.ownerId = result.updatedSessionOwnerIds[sessionId] ?? result.ownerId
|
||||
session.ownerName = session.ownerId
|
||||
session.ownerStatus = 'resolved'
|
||||
session.ownerExcluded = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -844,16 +846,22 @@ export const useSessionStore = defineStore(
|
||||
session.ownerId = result.ownerId
|
||||
session.ownerName = result.ownerId
|
||||
session.ownerStatus = 'resolved'
|
||||
session.ownerExcluded = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 本会话不再提醒"选择我是谁"(仅抑制弹窗,不影响自动补全)
|
||||
* 用户确认当前对话中没有自己:排除个人统计与自动 owner 补全
|
||||
*/
|
||||
function dismissOwnerPrompt(id: string): Promise<boolean> {
|
||||
return useDataService().dismissOwnerPrompt(id)
|
||||
async function excludeOwnerSession(id: string): Promise<boolean> {
|
||||
const success = await useDataService().excludeOwnerSession(id)
|
||||
if (success) {
|
||||
const session = sessions.value.find((item) => item.id === id)
|
||||
if (session) session.ownerExcluded = true
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -961,7 +969,7 @@ export const useSessionStore = defineStore(
|
||||
updateSessionOwnerId,
|
||||
setOwnerAndApplyProfile,
|
||||
tryApplyOwnerProfile,
|
||||
dismissOwnerPrompt,
|
||||
excludeOwnerSession,
|
||||
togglePinSession,
|
||||
isPinned,
|
||||
// 批量导入
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface AnalysisSession {
|
||||
ownerId: string | null // 所有者/导出者的 platformId
|
||||
ownerName: string | null // 所有者在当前会话中的显示名称
|
||||
ownerStatus: 'resolved' | 'missing' | 'unresolved' // 所有者是否能匹配当前会话成员
|
||||
ownerExcluded: boolean // 用户已确认当前对话中没有自己,不参与依赖本人身份的统计
|
||||
memberAvatar: string | null // 私聊对方头像(base64 Data URL)
|
||||
lastMessageTs: number | null // 最后一条消息时间戳(秒)
|
||||
summaryCount: number // 已生成摘要的会话片段数
|
||||
|
||||
Reference in new issue
Block a user