diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 4bddd2fa..dbce4487 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -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([ ['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 }) } }) diff --git a/packages/http-routes/src/routes/web/global-insight.ts b/packages/http-routes/src/routes/web/global-insight.ts index 39a40721..f0d89b34 100644 --- a/packages/http-routes/src/routes/web/global-insight.ts +++ b/packages/http-routes/src/routes/web/global-insight.ts @@ -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 + Pick 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()]) diff --git a/packages/http-routes/src/routes/web/sessions.ts b/packages/http-routes/src/routes/web/sessions.ts index 10a0695c..19a87a57 100644 --- a/packages/http-routes/src/routes/web/sessions.ts +++ b/packages/http-routes/src/routes/web/sessions.ts @@ -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 } }) } diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts index 37a1aad5..437d6218 100644 --- a/packages/node-runtime/src/preferences.test.ts +++ b/packages/node-runtime/src/preferences.test.ts @@ -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 { diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index ab8ee9d6..34cd95af 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -62,7 +62,11 @@ const DEFAULTS: Preferences = { filterHistory: [], thinkingLevels: {}, ownerProfilesByPlatform: {}, - ownerPromptDismissedSessionIds: [], + ownerExcludedSessionIds: [], +} + +type LegacyPreferences = Partial & { + 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 - 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 + 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, rawContent: string diff --git a/packages/node-runtime/src/services/global-insight/compute.test.ts b/packages/node-runtime/src/services/global-insight/compute.test.ts index 87300862..d9549dbf 100644 --- a/packages/node-runtime/src/services/global-insight/compute.test.ts +++ b/packages/node-runtime/src/services/global-insight/compute.test.ts @@ -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) }) diff --git a/packages/node-runtime/src/services/global-insight/compute.ts b/packages/node-runtime/src/services/global-insight/compute.ts index 69dd9d99..402f0dfe 100644 --- a/packages/node-runtime/src/services/global-insight/compute.ts +++ b/packages/node-runtime/src/services/global-insight/compute.ts @@ -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 diff --git a/packages/node-runtime/src/services/global-insight/service.test.ts b/packages/node-runtime/src/services/global-insight/service.test.ts index ded32b57..f2a2aac2 100644 --- a/packages/node-runtime/src/services/global-insight/service.test.ts +++ b/packages/node-runtime/src/services/global-insight/service.test.ts @@ -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 }) => diff --git a/packages/node-runtime/src/services/global-insight/service.ts b/packages/node-runtime/src/services/global-insight/service.ts index d9f83818..1a692b22 100644 --- a/packages/node-runtime/src/services/global-insight/service.ts +++ b/packages/node-runtime/src/services/global-insight/service.ts @@ -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 { diff --git a/packages/node-runtime/src/services/global-insight/session-scope.ts b/packages/node-runtime/src/services/global-insight/session-scope.ts new file mode 100644 index 00000000..ed7480e8 --- /dev/null +++ b/packages/node-runtime/src/services/global-insight/session-scope.ts @@ -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)) +} diff --git a/packages/node-runtime/src/services/global-insight/signature.ts b/packages/node-runtime/src/services/global-insight/signature.ts index c0006968..32d40d30 100644 --- a/packages/node-runtime/src/services/global-insight/signature.ts +++ b/packages/node-runtime/src/services/global-insight/signature.ts @@ -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('|') diff --git a/packages/node-runtime/src/services/global-insight/time-investment-compute.test.ts b/packages/node-runtime/src/services/global-insight/time-investment-compute.test.ts index ac697ab0..92648999 100644 --- a/packages/node-runtime/src/services/global-insight/time-investment-compute.test.ts +++ b/packages/node-runtime/src/services/global-insight/time-investment-compute.test.ts @@ -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) }) diff --git a/packages/node-runtime/src/services/global-insight/time-investment-compute.ts b/packages/node-runtime/src/services/global-insight/time-investment-compute.ts index cf9b7cb7..57b0464c 100644 --- a/packages/node-runtime/src/services/global-insight/time-investment-compute.ts +++ b/packages/node-runtime/src/services/global-insight/time-investment-compute.ts @@ -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 diff --git a/packages/node-runtime/src/services/global-insight/time-investment-service.test.ts b/packages/node-runtime/src/services/global-insight/time-investment-service.test.ts index e5491080..4e7b8867 100644 --- a/packages/node-runtime/src/services/global-insight/time-investment-service.test.ts +++ b/packages/node-runtime/src/services/global-insight/time-investment-service.test.ts @@ -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() }) diff --git a/packages/node-runtime/src/services/global-insight/time-investment-service.ts b/packages/node-runtime/src/services/global-insight/time-investment-service.ts index 826a5195..a920e281 100644 --- a/packages/node-runtime/src/services/global-insight/time-investment-service.ts +++ b/packages/node-runtime/src/services/global-insight/time-investment-service.ts @@ -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 { diff --git a/packages/node-runtime/src/services/global-insight/time-investment-signature.ts b/packages/node-runtime/src/services/global-insight/time-investment-signature.ts index 7c80dd85..28d5633b 100644 --- a/packages/node-runtime/src/services/global-insight/time-investment-signature.ts +++ b/packages/node-runtime/src/services/global-insight/time-investment-signature.ts @@ -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('|') diff --git a/packages/node-runtime/src/services/global-insight/worker-entry.ts b/packages/node-runtime/src/services/global-insight/worker-entry.ts index f16a76f3..0d163206 100644 --- a/packages/node-runtime/src/services/global-insight/worker-entry.ts +++ b/packages/node-runtime/src/services/global-insight/worker-entry.ts @@ -17,6 +17,7 @@ interface StartupOptions { nativeBinding?: string signature: string range: AnnualSummaryRange + excludedSessionIds: string[] } async function main(): Promise { @@ -35,6 +36,7 @@ async function main(): Promise { 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 { adapter, signature: options.signature, range: options.range, + excludedSessionIds: options.excludedSessionIds, factsCacheDir: getGlobalInsightFactsCacheDir(pathProvider.getUserDataDir()), onProgress: (progress) => parentPort?.postMessage({ type: 'progress', progress }), } diff --git a/packages/node-runtime/src/services/global-insight/worker-runner.ts b/packages/node-runtime/src/services/global-insight/worker-runner.ts index f49a7068..e6aff669 100644 --- a/packages/node-runtime/src/services/global-insight/worker-runner.ts +++ b/packages/node-runtime/src/services/global-insight/worker-runner.ts @@ -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((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((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 ) diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index a87d2776..5b8b2382 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -28,7 +28,8 @@ export type { MembersPaginatedDTO } from './member-service' export { tryApplyOwnerProfile, setOwnerAndApplyProfile, - dismissOwnerPrompt, + excludeOwnerSession, + clearOwnerExclusions, clearSessionOwner, } from './owner-profile-service' export type { diff --git a/packages/node-runtime/src/services/owner-profile-service.test.ts b/packages/node-runtime/src/services/owner-profile-service.test.ts index dad3e777..48055f95 100644 --- a/packages/node-runtime/src/services/owner-profile-service.test.ts +++ b/packages/node-runtime/src/services/owner-profile-service.test.ts @@ -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) => { diff --git a/packages/node-runtime/src/services/owner-profile-service.ts b/packages/node-runtime/src/services/owner-profile-service.ts index 79243355..515bd199 100644 --- a/packages/node-runtime/src/services/owner-profile-service.ts +++ b/packages/node-runtime/src/services/owner-profile-service.ts @@ -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 = {} 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 ): 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): 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. diff --git a/packages/node-runtime/src/services/session-service.ts b/packages/node-runtime/src/services/session-service.ts index ce1ec24d..f743e842 100644 --- a/packages/node-runtime/src/services/session-service.ts +++ b/packages/node-runtime/src/services/session-service.ts @@ -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) diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 54cad099..951da7f3 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -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 /** Platform-level owner identity, keyed by platform (e.g. 'whatsapp'). */ ownerProfilesByPlatform: Record - /** 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 { diff --git a/src/components/analysis/member/OwnerPromptModal.vue b/src/components/analysis/member/OwnerPromptModal.vue index 06ccb5bb..74056283 100644 --- a/src/components/analysis/member/OwnerPromptModal.vue +++ b/src/components/analysis/member/OwnerPromptModal.vue @@ -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') }}
diff --git a/src/components/common/Settings/BatchManageTab.vue b/src/components/common/Settings/BatchManageTab.vue index acacc1a6..6ae4d2b0 100644 --- a/src/components/common/Settings/BatchManageTab.vue +++ b/src/components/common/Settings/BatchManageTab.vue @@ -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(() => { > diff --git a/src/i18n/locales/en-US/members.json b/src/i18n/locales/en-US/members.json index c8ca1230..a28b32de 100644 --- a/src/i18n/locales/en-US/members.json +++ b/src/i18n/locales/en-US/members.json @@ -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", diff --git a/src/i18n/locales/ja-JP/members.json b/src/i18n/locales/ja-JP/members.json index 522ea7b3..2d1b717b 100644 --- a/src/i18n/locales/ja-JP/members.json +++ b/src/i18n/locales/ja-JP/members.json @@ -8,7 +8,7 @@ "empty": "メンバーデータがありません", "loadError": "メンバーデータの読み込みに失敗しました。再試行してください", "later": "後で", - "dontRemind": "このチャットでは再通知しない", + "notInConversation": "この会話に私はいません", "save": "保存", "clear": "現在の選択をクリア", "saved": "あなたの身元を設定しました", diff --git a/src/i18n/locales/zh-CN/members.json b/src/i18n/locales/zh-CN/members.json index de200a9b..f12f45ba 100644 --- a/src/i18n/locales/zh-CN/members.json +++ b/src/i18n/locales/zh-CN/members.json @@ -8,7 +8,7 @@ "empty": "暂无成员数据", "loadError": "成员数据加载失败,请重试", "later": "稍后", - "dontRemind": "本会话不再提醒", + "notInConversation": "当前对话中没有我", "save": "保存", "clear": "清除当前选择", "saved": "已设置你的身份", diff --git a/src/i18n/locales/zh-TW/members.json b/src/i18n/locales/zh-TW/members.json index d5eb360b..d2c9eab4 100644 --- a/src/i18n/locales/zh-TW/members.json +++ b/src/i18n/locales/zh-TW/members.json @@ -8,7 +8,7 @@ "empty": "暫無成員資料", "loadError": "成員資料載入失敗,請重試", "later": "稍後", - "dontRemind": "此對話不再提醒", + "notInConversation": "目前對話中沒有我", "save": "儲存", "clear": "清除目前選擇", "saved": "已設定你的身分", diff --git a/src/services/data/browser.test.ts b/src/services/data/browser.test.ts index 42bb96d8..576ff760 100644 --- a/src/services/data/browser.test.ts +++ b/src/services/data/browser.test.ts @@ -112,6 +112,7 @@ describe('BrowserDataAdapter', () => { ownerId: null, ownerName: null, ownerStatus: 'missing', + ownerExcluded: false, memberAvatar: null, lastMessageTs: 2, summaryCount: 0, diff --git a/src/services/data/browser.ts b/src/services/data/browser.ts index b5b64451..50df6342 100644 --- a/src/services/data/browser.ts +++ b/src/services/data/browser.ts @@ -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, diff --git a/src/services/data/fetch.ts b/src/services/data/fetch.ts index b8186639..375b3429 100644 --- a/src/services/data/fetch.ts +++ b/src/services/data/fetch.ts @@ -124,8 +124,8 @@ export class FetchDataAdapter implements DataAdapter { return post(`/sessions/${sessionId}/owner/select`, { ownerPlatformId }) } - async dismissOwnerPrompt(sessionId: string): Promise { - const result = await post<{ success: boolean }>(`/sessions/${sessionId}/owner/dismiss-prompt`, {}) + async excludeOwnerSession(sessionId: string): Promise { + const result = await post<{ success: boolean }>(`/sessions/${sessionId}/owner/exclude`, {}) return result.success } diff --git a/src/services/data/types.ts b/src/services/data/types.ts index 5ea56bde..92879586 100644 --- a/src/services/data/types.ts +++ b/src/services/data/types.ts @@ -154,7 +154,7 @@ export interface DataAdapter { updateSessionOwnerId(sessionId: string, ownerId: string | null): Promise tryApplyOwnerProfile(sessionId: string): Promise setOwnerAndApplyProfile(sessionId: string, ownerPlatformId: string): Promise - dismissOwnerPrompt(sessionId: string): Promise + excludeOwnerSession(sessionId: string): Promise // ==================== 联系人 ==================== diff --git a/src/stores/session.ts b/src/stores/session.ts index 669d503e..e47252ea 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -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 { - return useDataService().dismissOwnerPrompt(id) + async function excludeOwnerSession(id: string): Promise { + 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, // 批量导入 diff --git a/src/types/base.ts b/src/types/base.ts index a3ca9365..ee3b013d 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -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 // 已生成摘要的会话片段数