Files
Agent-System/web/src/data/sessionPrivacy.ts
T

175 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ProjectSession, ChatMessage, StageKey } from '../types/workbench';
/**
* 私聊会话 ID 约定:sess-{projectKey}-u{userId}
* 同一项目下,不同用户拥有完全独立的 session,互不可见。
*/
export function buildPrivateSessionId(projectKey: string, userId: number): string {
return `sess-${projectKey}-u${userId}`;
}
export function filterSessionsForUser(
sessions: ProjectSession[],
userId: number
): ProjectSession[] {
return sessions.filter((s) => s.userId === userId);
}
/**
* 登录 / 切换用户后,解析应激活的私聊会话:
* 1) 若当前 active 已属于该用户且该会话确有消息(或无更好候选)则保留
* 2) 同 projectId 下优先选择「有消息」的私聊会话,避免落到 ensure 出来的空壳 sess-p{N}-u{id}
* 3) 否则取该用户消息最多的会话 / 第一条会话
*/
export function resolveActiveSessionForUser(
sessions: ProjectSession[],
userId: number,
preferredProjectId?: number,
currentActiveId?: string,
messageCounts?: Record<string, number>
): string {
const mine = filterSessionsForUser(sessions, userId);
if (mine.length === 0) return '';
const countOf = (sessionId: string) =>
messageCounts?.[sessionId] ??
mine.find((s) => s.sessionId === sessionId)?.messageCount ??
0;
const rank = (a: ProjectSession, b: ProjectSession) => {
const diff = countOf(b.sessionId) - countOf(a.sessionId);
if (diff !== 0) return diff;
// 同消息量时,优先非 ensure 生成的空壳 ID(sess-p数字-u)
const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0;
const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0;
return aShell - bShell;
};
const pickBest = (candidates: ProjectSession[]) => {
if (candidates.length === 0) return '';
return [...candidates].sort(rank)[0].sessionId;
};
if (currentActiveId) {
const current = mine.find((s) => s.sessionId === currentActiveId);
if (current && countOf(currentActiveId) > 0) {
return currentActiveId;
}
}
if (preferredProjectId != null) {
const sameProject = mine.filter((s) => s.projectId === preferredProjectId);
const best = pickBest(sameProject);
if (best) return best;
}
// 当前 active 若为空壳,改选同用户最佳会话
return pickBest(mine);
}
/** 同一 (projectId, userId) 只保留一条:优先有消息 / 非空壳 ID 的会话 */
export function dedupePrivateSessions(
sessions: ProjectSession[],
messageCounts?: Record<string, number>
): ProjectSession[] {
const groups = new Map<string, ProjectSession[]>();
for (const s of sessions) {
const key = `${s.projectId}:${s.userId}`;
const list = groups.get(key) || [];
list.push(s);
groups.set(key, list);
}
const result: ProjectSession[] = [];
for (const list of groups.values()) {
if (list.length === 1) {
result.push(list[0]);
continue;
}
const countOf = (id: string) =>
messageCounts?.[id] ?? list.find((s) => s.sessionId === id)?.messageCount ?? 0;
list.sort((a, b) => {
const diff = countOf(b.sessionId) - countOf(a.sessionId);
if (diff !== 0) return diff;
const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0;
const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0;
return aShell - bShell;
});
result.push(list[0]);
}
return result;
}
/** 确保用户对每个已知项目都有独立私聊会话(无则克隆项目元数据新建空会话) */
export function ensurePrivateSessionsForUser(
sessions: ProjectSession[],
userId: number
): ProjectSession[] {
const byProject = new Map<number, ProjectSession>();
for (const s of sessions) {
if (!byProject.has(s.projectId)) {
byProject.set(s.projectId, s);
}
}
const result = [...sessions];
for (const [projectId, template] of byProject) {
const exists = result.some((s) => s.projectId === projectId && s.userId === userId);
if (!exists) {
// 优先复用已知项目短名,避免与默认 sess-cusa-u* 并存两套空壳
const projectKey =
projectId === 3
? 'cusa'
: projectId === 1
? 'endo'
: projectId === 2
? 'ultrasound'
: projectId === 5201
? 'neuropulse'
: `p${projectId}`;
result.push({
sessionId: buildPrivateSessionId(projectKey, userId),
userId,
projectId,
title: template.title,
deviceType: template.deviceType,
stage: template.stage as StageKey,
stageLabel: template.stageLabel,
messageCount: 0,
updatedAt: '刚刚',
});
}
}
return dedupePrivateSessions(result);
}
/** 断言:任意消息不得跨用户 session 泄漏 */
export function assertNoCrossUserChatLeak(
sessions: ProjectSession[],
messages: ChatMessage[]
): { ok: boolean; leaks: string[] } {
const sessionOwner = new Map(sessions.map((s) => [s.sessionId, s.userId]));
const leaks: string[] = [];
for (const m of messages) {
const owner = sessionOwner.get(m.sessionId);
if (owner == null) continue;
if (m.role === 'user' && m.authorName) {
const otherOwners = sessions.filter((s) => s.userId !== owner);
for (const o of otherOwners) {
const otherName = guessUserNameById(o.userId);
if (otherName && m.authorName === otherName) {
leaks.push(
`session=${m.sessionId} ownerUserId=${owner} 含异主用户发言 author=${m.authorName}`
);
}
}
}
}
return { ok: leaks.length === 0, leaks };
}
function guessUserNameById(userId?: number): string | null {
if (userId === 1) return '张工';
if (userId === 2) return '李主管';
if (userId === 3) return '王总监';
return null;
}