feat(owner): exclude sessions without current user

This commit is contained in:
digua committed 2026-08-11 10:38:05 +08:00
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 {
+34 -6
View File
@@ -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
)
+2 -1
View File
@@ -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)
+11 -5
View File
@@ -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"
/>
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -8,7 +8,7 @@
"empty": "メンバーデータがありません",
"loadError": "メンバーデータの読み込みに失敗しました。再試行してください",
"later": "後で",
"dontRemind": "このチャットでは再通知しない",
"notInConversation": "この会話に私はいません",
"save": "保存",
"clear": "現在の選択をクリア",
"saved": "あなたの身元を設定しました",
+1 -1
View File
@@ -8,7 +8,7 @@
"empty": "暂无成员数据",
"loadError": "成员数据加载失败,请重试",
"later": "稍后",
"dontRemind": "本会话不再提醒",
"notInConversation": "当前对话中没有我",
"save": "保存",
"clear": "清除当前选择",
"saved": "已设置你的身份",
+1 -1
View File
@@ -8,7 +8,7 @@
"empty": "暫無成員資料",
"loadError": "成員資料載入失敗,請重試",
"later": "稍後",
"dontRemind": "此對話不再提醒",
"notInConversation": "目前對話中沒有我",
"save": "儲存",
"clear": "清除目前選擇",
"saved": "已設定你的身分",
+1
View File
@@ -112,6 +112,7 @@ describe('BrowserDataAdapter', () => {
ownerId: null,
ownerName: null,
ownerStatus: 'missing',
ownerExcluded: false,
memberAvatar: null,
lastMessageTs: 2,
summaryCount: 0,
+1
View File
@@ -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,
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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,
// 批量导入
+1
View File
@@ -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 // 已生成摘要的会话片段数