mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(owner): add platform-level owner profile types and matching
Add deterministic owner matching in core (exact platformId first, then normalized-name fallback limited to whatsapp/line/instagram), plus OwnerProfile preferences fields shared across Electron and CLI Web. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82a6be4fcb
commit
7589515eeb
6 files changed
+362
No files matched your search
@@ -254,6 +254,17 @@ export {
|
||||
} from './ai'
|
||||
export type { StreamParserEvent, ThinkingLevel, ThinkingCompat } from './ai'
|
||||
|
||||
// Owner profile matching(跨会话"我是谁"识别)
|
||||
export {
|
||||
NAME_MATCH_PLATFORMS,
|
||||
isNameMatchPlatform,
|
||||
normalizeOwnerName,
|
||||
collectCandidateNames,
|
||||
mergeConfirmedNames,
|
||||
matchOwnerProfile,
|
||||
} from './owner'
|
||||
export type { OwnerMatchCandidate, OwnerMatchResult } from './owner'
|
||||
|
||||
// Import utilities
|
||||
export { generateMessageKey, buildMemberIdMap } from './import'
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
NAME_MATCH_PLATFORMS,
|
||||
isNameMatchPlatform,
|
||||
normalizeOwnerName,
|
||||
collectCandidateNames,
|
||||
mergeConfirmedNames,
|
||||
matchOwnerProfile,
|
||||
} from './owner-matching'
|
||||
export type { OwnerMatchCandidate, OwnerMatchResult } from './owner-matching'
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Unit tests for owner profile matching (deterministic "who am I" resolution).
|
||||
*
|
||||
* Run: npx tsx --test packages/core/src/owner/owner-matching.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { OwnerProfile } from '@openchatlab/shared-types'
|
||||
import {
|
||||
normalizeOwnerName,
|
||||
collectCandidateNames,
|
||||
mergeConfirmedNames,
|
||||
matchOwnerProfile,
|
||||
isNameMatchPlatform,
|
||||
} from './owner-matching'
|
||||
|
||||
function makeProfile(overrides?: Partial<OwnerProfile>): OwnerProfile {
|
||||
return {
|
||||
platformId: 'Alice',
|
||||
displayName: 'Alice',
|
||||
confirmedNames: ['Alice'],
|
||||
matchMode: 'name',
|
||||
updatedAt: 1700000000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('normalizeOwnerName', () => {
|
||||
it('trims and collapses whitespace', () => {
|
||||
assert.equal(normalizeOwnerName(' Alice Smith '), 'alice smith')
|
||||
})
|
||||
|
||||
it('removes invisible direction/control characters (LRM/RLM/zero-width/BOM)', () => {
|
||||
assert.equal(normalizeOwnerName('\u200EAlice\u200F'), 'alice')
|
||||
assert.equal(normalizeOwnerName('\u202AAlice\u202C'), 'alice')
|
||||
assert.equal(normalizeOwnerName('A\u200Blice'), 'alice')
|
||||
assert.equal(normalizeOwnerName('\uFEFFAlice'), 'alice')
|
||||
})
|
||||
|
||||
it('applies NFKC normalization (full-width to half-width)', () => {
|
||||
assert.equal(normalizeOwnerName('Alice 123'), 'alice 123')
|
||||
})
|
||||
|
||||
it('lowercases for case-insensitive comparison', () => {
|
||||
assert.equal(normalizeOwnerName('ALICE'), 'alice')
|
||||
})
|
||||
|
||||
it('keeps CJK names intact', () => {
|
||||
assert.equal(normalizeOwnerName(' 王小明 '), '王小明')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectCandidateNames', () => {
|
||||
it('collects platformId, accountName, groupNickname, aliases and displayName', () => {
|
||||
const names = collectCandidateNames({
|
||||
platformId: 'u001',
|
||||
accountName: 'Alice',
|
||||
groupNickname: 'Ali',
|
||||
aliases: ['A.', 'Allie'],
|
||||
displayName: 'Ali',
|
||||
})
|
||||
assert.deepEqual(names, ['u001', 'Alice', 'Ali', 'A.', 'Allie'])
|
||||
})
|
||||
|
||||
it('skips empty and whitespace-only values', () => {
|
||||
const names = collectCandidateNames({
|
||||
platformId: 'u001',
|
||||
accountName: '',
|
||||
groupNickname: ' ',
|
||||
aliases: [''],
|
||||
displayName: null,
|
||||
})
|
||||
assert.deepEqual(names, ['u001'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeConfirmedNames', () => {
|
||||
it('merges member names into existing list without duplicates, preserving order', () => {
|
||||
const merged = mergeConfirmedNames(['Alice', 'Ali'], {
|
||||
platformId: 'Alice',
|
||||
accountName: 'Alice Smith',
|
||||
groupNickname: 'Ali',
|
||||
aliases: ['Allie'],
|
||||
})
|
||||
assert.deepEqual(merged, ['Alice', 'Ali', 'Alice Smith', 'Allie'])
|
||||
})
|
||||
|
||||
it('keeps original strings without normalization', () => {
|
||||
const merged = mergeConfirmedNames([], { platformId: ' Alice ' })
|
||||
assert.deepEqual(merged, [' Alice '])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchOwnerProfile', () => {
|
||||
it('matches exact platformId on any platform', () => {
|
||||
const result = matchOwnerProfile('weixin', makeProfile({ platformId: 'wx_123', confirmedNames: [] }), [
|
||||
{ platformId: 'wx_123', accountName: 'Me' },
|
||||
{ platformId: 'wx_456', accountName: 'Other' },
|
||||
])
|
||||
assert.deepEqual(result, { type: 'exact', platformId: 'wx_123' })
|
||||
})
|
||||
|
||||
it('falls back to name matching only on allowlisted platforms', () => {
|
||||
const members = [
|
||||
{ platformId: 'Alice Smith', accountName: 'Alice Smith' },
|
||||
{ platformId: 'Bob', accountName: 'Bob' },
|
||||
]
|
||||
const profile = makeProfile({ platformId: 'Alice', confirmedNames: ['alice smith'] })
|
||||
|
||||
assert.deepEqual(matchOwnerProfile('whatsapp', profile, members), {
|
||||
type: 'name',
|
||||
platformId: 'Alice Smith',
|
||||
})
|
||||
// weixin has stable IDs; no name fallback
|
||||
assert.deepEqual(matchOwnerProfile('weixin', profile, members), { type: 'none' })
|
||||
// unknown platform never uses name fallback
|
||||
assert.deepEqual(matchOwnerProfile('unknown', profile, members), { type: 'none' })
|
||||
})
|
||||
|
||||
it('matches names with invisible characters and width differences', () => {
|
||||
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
|
||||
{ platformId: '\u200EAlice\u200F' },
|
||||
{ platformId: 'Bob' },
|
||||
])
|
||||
assert.deepEqual(result, { type: 'name', platformId: '\u200EAlice\u200F' })
|
||||
})
|
||||
|
||||
it('matches via aliases and groupNickname', () => {
|
||||
const result = matchOwnerProfile('line', makeProfile({ platformId: 'Allie W', confirmedNames: ['Allie'] }), [
|
||||
{ platformId: 'Alice', accountName: 'Alice', aliases: ['Allie'] },
|
||||
{ platformId: 'Bob' },
|
||||
])
|
||||
assert.deepEqual(result, { type: 'name', platformId: 'Alice' })
|
||||
})
|
||||
|
||||
it('returns ambiguous when multiple members match', () => {
|
||||
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
|
||||
{ platformId: 'u1', accountName: 'Alice' },
|
||||
{ platformId: 'u2', groupNickname: 'Alice' },
|
||||
])
|
||||
assert.equal(result.type, 'ambiguous')
|
||||
assert.deepEqual(result.type === 'ambiguous' ? result.platformIds : [], ['u1', 'u2'])
|
||||
})
|
||||
|
||||
it('returns none when nothing matches or confirmed names are empty', () => {
|
||||
const members = [{ platformId: 'Bob' }]
|
||||
assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), members), {
|
||||
type: 'none',
|
||||
})
|
||||
assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: [' '] }), members), {
|
||||
type: 'none',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use substring containment', () => {
|
||||
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
|
||||
{ platformId: 'Alice Smith' },
|
||||
])
|
||||
assert.deepEqual(result, { type: 'none' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNameMatchPlatform', () => {
|
||||
it('only allows whatsapp, line and instagram', () => {
|
||||
assert.equal(isNameMatchPlatform('whatsapp'), true)
|
||||
assert.equal(isNameMatchPlatform('line'), true)
|
||||
assert.equal(isNameMatchPlatform('instagram'), true)
|
||||
assert.equal(isNameMatchPlatform('weixin'), false)
|
||||
assert.equal(isNameMatchPlatform('qq'), false)
|
||||
assert.equal(isNameMatchPlatform('telegram'), false)
|
||||
assert.equal(isNameMatchPlatform('unknown'), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Owner profile matching — platform-independent "who am I" resolution.
|
||||
*
|
||||
* Given a platform-level owner profile (stored in preferences) and the member
|
||||
* list of a session, decide which member is the owner. Matching is strictly
|
||||
* deterministic: exact platformId first, then normalized-name equality for an
|
||||
* allowlisted set of platforms whose exports lack stable native IDs.
|
||||
* No fuzzy matching, no heuristics, no LLM inference.
|
||||
*/
|
||||
|
||||
import type { OwnerProfile } from '@openchatlab/shared-types'
|
||||
|
||||
/**
|
||||
* Platforms whose text exports use display names as platformId, so a
|
||||
* normalized-name fallback is safe and necessary. All other platforms
|
||||
* (including 'unknown') match by exact platformId only.
|
||||
*/
|
||||
export const NAME_MATCH_PLATFORMS: ReadonlySet<string> = new Set(['whatsapp', 'line', 'instagram'])
|
||||
|
||||
export function isNameMatchPlatform(platform: string): boolean {
|
||||
return NAME_MATCH_PLATFORMS.has(platform)
|
||||
}
|
||||
|
||||
/** Minimal member shape needed for owner matching. */
|
||||
export interface OwnerMatchCandidate {
|
||||
platformId: string
|
||||
accountName?: string | null
|
||||
groupNickname?: string | null
|
||||
aliases?: string[] | null
|
||||
/** Computed display name, if the caller already derived one. */
|
||||
displayName?: string | null
|
||||
}
|
||||
|
||||
export type OwnerMatchResult =
|
||||
| { type: 'exact'; platformId: string }
|
||||
| { type: 'name'; platformId: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'ambiguous'; platformIds: string[] }
|
||||
|
||||
// Invisible direction/control characters that chat exports commonly embed
|
||||
// around names (LRM/RLM, directional embeddings/isolates, zero-width chars, BOM).
|
||||
const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g
|
||||
|
||||
/**
|
||||
* Deterministic name normalization for matching:
|
||||
* strip invisible chars → NFKC → collapse whitespace → trim → lowercase.
|
||||
* Stored names keep their original form; normalize only at compare time.
|
||||
*/
|
||||
export function normalizeOwnerName(name: string): string {
|
||||
return name.replace(INVISIBLE_CHARS_RE, '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** Collect all candidate name strings of a member (original, non-normalized, deduped, non-empty). */
|
||||
export function collectCandidateNames(member: OwnerMatchCandidate): string[] {
|
||||
const names = [
|
||||
member.platformId,
|
||||
member.accountName ?? '',
|
||||
member.groupNickname ?? '',
|
||||
...(member.aliases ?? []),
|
||||
member.displayName ?? '',
|
||||
]
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const name of names) {
|
||||
if (!name || !name.trim()) continue
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a member's candidate names into an existing confirmedNames list.
|
||||
* Keeps original strings; dedupes by exact string equality, preserving order.
|
||||
*/
|
||||
export function mergeConfirmedNames(existing: string[], member: OwnerMatchCandidate): string[] {
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const name of [...existing, ...collectCandidateNames(member)]) {
|
||||
if (!name || !name.trim()) continue
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a platform owner profile against a session's members.
|
||||
*
|
||||
* 1. Exact platformId match wins immediately.
|
||||
* 2. Name fallback only for NAME_MATCH_PLATFORMS: a member matches when any
|
||||
* of its normalized candidate names equals any normalized confirmed name.
|
||||
* 3. Exactly one member must match; zero → none, multiple → ambiguous.
|
||||
*/
|
||||
export function matchOwnerProfile(
|
||||
platform: string,
|
||||
profile: OwnerProfile,
|
||||
members: OwnerMatchCandidate[]
|
||||
): OwnerMatchResult {
|
||||
const exact = members.find((m) => m.platformId === profile.platformId)
|
||||
if (exact) {
|
||||
return { type: 'exact', platformId: exact.platformId }
|
||||
}
|
||||
|
||||
if (!isNameMatchPlatform(platform)) {
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
const confirmed = new Set(profile.confirmedNames.map(normalizeOwnerName).filter((n) => n.length > 0))
|
||||
if (confirmed.size === 0) {
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
const matched = members.filter((m) =>
|
||||
collectCandidateNames(m).some((name) => confirmed.has(normalizeOwnerName(name)))
|
||||
)
|
||||
|
||||
if (matched.length === 1) {
|
||||
return { type: 'name', platformId: matched[0].platformId }
|
||||
}
|
||||
if (matched.length > 1) {
|
||||
return { type: 'ambiguous', platformIds: matched.map((m) => m.platformId) }
|
||||
}
|
||||
return { type: 'none' }
|
||||
}
|
||||
@@ -68,6 +68,8 @@ const DEFAULTS: Preferences = {
|
||||
},
|
||||
filterHistory: [],
|
||||
thinkingLevels: {},
|
||||
ownerProfilesByPlatform: {},
|
||||
ownerPromptDismissedSessionIds: [],
|
||||
}
|
||||
|
||||
export class PreferencesManager {
|
||||
@@ -152,6 +154,8 @@ export class PreferencesManager {
|
||||
wordFilter: partial.wordFilter ? { ...DEFAULTS.wordFilter, ...partial.wordFilter } : { ...DEFAULTS.wordFilter },
|
||||
filterHistory: partial.filterHistory ?? DEFAULTS.filterHistory,
|
||||
thinkingLevels: partial.thinkingLevels ?? DEFAULTS.thinkingLevels,
|
||||
ownerProfilesByPlatform: partial.ownerProfilesByPlatform ?? DEFAULTS.ownerProfilesByPlatform,
|
||||
ownerPromptDismissedSessionIds: partial.ownerPromptDismissedSessionIds ?? DEFAULTS.ownerPromptDismissedSessionIds,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,39 @@ export interface FilterHistoryItem {
|
||||
selectedSessionIds?: number[]
|
||||
}
|
||||
|
||||
export type OwnerMatchMode = 'platform_id' | 'name'
|
||||
|
||||
/**
|
||||
* Platform-level owner identity ("who am I" on this chat platform).
|
||||
* Stored in preferences.json and shared across sessions of the same platform.
|
||||
*/
|
||||
export interface OwnerProfile {
|
||||
platformId: string
|
||||
displayName: string
|
||||
/** Original (non-normalized) names confirmed by the user; normalization happens at match time. */
|
||||
confirmedNames: string[]
|
||||
matchMode: OwnerMatchMode
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ApplyOwnerProfileReason = 'no_profile' | 'no_match' | 'ambiguous' | 'already_set' | 'missing_session'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface SetOwnerAndApplyProfileResult {
|
||||
sessionId: string
|
||||
platform: string
|
||||
ownerId: string
|
||||
/** Other same-platform sessions auto-filled by the updated profile. */
|
||||
updatedSessionIds: string[]
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
pinnedSessionIds: string[]
|
||||
aiPreprocessConfig: AIPreprocessConfig
|
||||
@@ -199,6 +232,10 @@ export interface Preferences {
|
||||
filterHistory: FilterHistoryItem[]
|
||||
/** Per-model thinking level, keyed by `${configId}:${modelId}`. */
|
||||
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[]
|
||||
}
|
||||
|
||||
export interface UiConfig {
|
||||
|
||||
Reference in new issue
Block a user