Drop window-cache payloads that lack a schema field. Prove a non-owner listing another user's sessions is forbidden. Correct the stale test schema note in CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import { ProjectSession, ChatMessage, DeliverableItem, ChatDayBucket } from '../types/workbench';
|
|
import { RoleDefinition } from '../types/rbac';
|
|
import { UserProfile } from '../types/docgov';
|
|
import { dedupePrivateSessions } from './sessionPrivacy';
|
|
import {
|
|
DEFAULT_SESSIONS,
|
|
DEFAULT_MESSAGES,
|
|
NEUROPULSE_ACTIVE_SESSION_ID,
|
|
} from '@demo-seeds';
|
|
import { isDemoSeedDocId, isDemoSeedProjectId } from './demoSeedIds';
|
|
import { enrichMessagesWithCategories } from '../engine/categoryHelper';
|
|
|
|
// v6:修正 NeuroPulse 驳回追问误挂「疲劳寿命」旧答复;对话 LLM 每轮独立工作区
|
|
const STORAGE_PREFIX = 'smdf_agent_workbench_v6_';
|
|
|
|
// CACHE_SCHEMA 是窗口缓存 JSON 的载荷版本。缺字段或版本不符时丢弃,避免旧结构被当成有效缓存。
|
|
export const CACHE_SCHEMA = 1;
|
|
|
|
type VersionedCache<T> = { schema: number; data: T };
|
|
|
|
function isVersionedCache<T>(value: unknown): value is VersionedCache<T> {
|
|
if (!value || typeof value !== 'object') return false;
|
|
const row = value as { schema?: unknown; data?: unknown };
|
|
return typeof row.schema === 'number' && 'data' in row;
|
|
}
|
|
|
|
export const STORAGE_KEYS = {
|
|
SESSIONS: `${STORAGE_PREFIX}sessions`,
|
|
ACTIVE_SESSION: `${STORAGE_PREFIX}active_session`,
|
|
MESSAGES: `${STORAGE_PREFIX}messages`,
|
|
DELIVERABLES: `${STORAGE_PREFIX}deliverables`,
|
|
DOC_CACHE: `${STORAGE_PREFIX}doc_cache`,
|
|
ROLES: `${STORAGE_PREFIX}roles`,
|
|
USERS: `${STORAGE_PREFIX}users_v7`,
|
|
};
|
|
|
|
export {
|
|
buildPrivateSessionId,
|
|
filterSessionsForUser,
|
|
resolveActiveSessionForUser,
|
|
ensurePrivateSessionsForUser,
|
|
dedupePrivateSessions,
|
|
} from './sessionPrivacy';
|
|
|
|
export {
|
|
DEFAULT_PROJECTS,
|
|
loadProjects,
|
|
saveProjects,
|
|
filterProjectSessionsForUser,
|
|
getProjectMembership,
|
|
} from './projectsStorage';
|
|
|
|
export { DEFAULT_SESSIONS, DEFAULT_MESSAGES, NEUROPULSE_ACTIVE_SESSION_ID };
|
|
|
|
// ----------------------------------------------------------------------------------
|
|
// 安全的 localStorage 存取包装器
|
|
// ----------------------------------------------------------------------------------
|
|
|
|
function safeGetItem<T>(key: string, fallback: T): T {
|
|
if (typeof window === 'undefined' || !window.localStorage) return fallback;
|
|
try {
|
|
const item = window.localStorage.getItem(key);
|
|
if (!item) return fallback;
|
|
return JSON.parse(item) as T;
|
|
} catch (e) {
|
|
console.warn(`[storage] Failed to parse localStorage item: ${key}`, e);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function safeSetItem<T>(key: string, val: T): void {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(val));
|
|
} catch (e) {
|
|
console.warn(`[storage] Failed to set localStorage item: ${key}`, e);
|
|
}
|
|
}
|
|
|
|
export function loadSessions(): ProjectSession[] {
|
|
const loaded = safeGetItem<ProjectSession[]>(STORAGE_KEYS.SESSIONS, DEFAULT_SESSIONS);
|
|
// 丢弃无 userId 的旧混聊会话,强制走每人独立私聊模型
|
|
const sanitized = loaded.filter((s) => typeof s.userId === 'number' && s.userId > 0);
|
|
const base = sanitized.length > 0 ? sanitized : DEFAULT_SESSIONS;
|
|
const existingIds = new Set(base.map((s) => s.sessionId));
|
|
const missing = DEFAULT_SESSIONS.filter((s) => !existingIds.has(s.sessionId));
|
|
const merged = missing.length > 0 ? [...missing, ...base] : base;
|
|
|
|
// 用默认消息数回填 messageCount,便于选中「有历史」的私聊而非空壳
|
|
const counts: Record<string, number> = {};
|
|
for (const m of DEFAULT_MESSAGES) {
|
|
counts[m.sessionId] = (counts[m.sessionId] || 0) + 1;
|
|
}
|
|
const withCounts = merged.map((s) => ({
|
|
...s,
|
|
messageCount: Math.max(s.messageCount || 0, counts[s.sessionId] || 0),
|
|
}));
|
|
return dedupePrivateSessions(withCounts, counts);
|
|
}
|
|
|
|
export function saveSessions(sessions: ProjectSession[]): void {
|
|
safeSetItem(STORAGE_KEYS.SESSIONS, sessions);
|
|
}
|
|
|
|
export function loadActiveSessionId(fallback = ''): string {
|
|
const resolvedFallback = fallback || NEUROPULSE_ACTIVE_SESSION_ID || '';
|
|
if (typeof window === 'undefined' || !window.localStorage) return resolvedFallback;
|
|
return window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || resolvedFallback;
|
|
}
|
|
|
|
export function saveActiveSessionId(sessionId: string): void {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
window.localStorage.setItem(STORAGE_KEYS.ACTIVE_SESSION, sessionId);
|
|
}
|
|
|
|
export function loadMessages(): ChatMessage[] {
|
|
const loaded = safeGetItem<ChatMessage[]>(STORAGE_KEYS.MESSAGES, DEFAULT_MESSAGES);
|
|
// 丢弃旧版混聊 sessionId,避免「有消息但当前私聊窗口对不上」
|
|
const legacyIds = new Set(['sess-cusa-001', 'sess-001', 'sess-002']);
|
|
const defaultMsgMap = new Map(DEFAULT_MESSAGES.map((m) => [m.id, m]));
|
|
const cleaned = loaded
|
|
.filter((m) => !legacyIds.has(m.sessionId))
|
|
.map((m) => {
|
|
const def = defaultMsgMap.get(m.id);
|
|
if (def) {
|
|
return {
|
|
...m,
|
|
content: def.content,
|
|
attachments: def.attachments || m.attachments,
|
|
intentTag: def.intentTag || m.intentTag,
|
|
category: def.category || m.category,
|
|
phaseEvents: def.phaseEvents || m.phaseEvents,
|
|
interactiveCard: def.interactiveCard || m.interactiveCard,
|
|
};
|
|
}
|
|
return m;
|
|
});
|
|
const existingIds = new Set(cleaned.map((m) => m.id));
|
|
const missing = DEFAULT_MESSAGES.filter((m) => !existingIds.has(m.id));
|
|
let result: ChatMessage[];
|
|
if (missing.length > 0) {
|
|
result = [...cleaned, ...missing];
|
|
} else if (cleaned.length === 0) {
|
|
result = [...DEFAULT_MESSAGES];
|
|
} else {
|
|
result = cleaned;
|
|
}
|
|
return enrichMessagesWithCategories(result);
|
|
}
|
|
|
|
// ==================================================================================
|
|
// P0: 会话窗口 LRU 缓存与输入草稿持久化 (替代原全量 MESSAGES 持久化)
|
|
// ==================================================================================
|
|
const WINDOW_CACHE_PREFIX = 'smdf_chat_window_';
|
|
const DRAFT_CACHE_PREFIX = 'smdf_chat_draft_';
|
|
const WINDOW_LRU_KEY = 'smdf_chat_window_lru_sessions';
|
|
const MAX_CACHED_WINDOWS = 3;
|
|
|
|
function getWindowCacheKey(sessionId: string, userId?: number): string {
|
|
if (userId && userId > 0) {
|
|
return `${WINDOW_CACHE_PREFIX}u${userId}_${sessionId}`;
|
|
}
|
|
return `${WINDOW_CACHE_PREFIX}${sessionId}`;
|
|
}
|
|
|
|
function getDraftCacheKey(sessionId: string): string {
|
|
return `${DRAFT_CACHE_PREFIX}${sessionId}`;
|
|
}
|
|
|
|
function updateWindowLRU(cacheKey: string): void {
|
|
const current = safeGetItem<string[]>(WINDOW_LRU_KEY, []);
|
|
const updated = [cacheKey, ...current.filter((id) => id !== cacheKey)];
|
|
if (updated.length > MAX_CACHED_WINDOWS) {
|
|
const evicted = updated.slice(MAX_CACHED_WINDOWS);
|
|
evicted.forEach((key) => {
|
|
if (typeof window !== 'undefined' && window.localStorage) {
|
|
window.localStorage.removeItem(key);
|
|
}
|
|
});
|
|
}
|
|
safeSetItem(WINDOW_LRU_KEY, updated.slice(0, MAX_CACHED_WINDOWS));
|
|
}
|
|
|
|
export function loadSessionWindowCache(sessionId: string, userId?: number): ChatDayBucket[] | null {
|
|
const raw = safeGetItem<unknown>(getWindowCacheKey(sessionId, userId), null);
|
|
if (!isVersionedCache<ChatDayBucket[]>(raw) || raw.schema !== CACHE_SCHEMA) return null;
|
|
return raw.data;
|
|
}
|
|
|
|
export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void {
|
|
const cacheKey = getWindowCacheKey(sessionId, userId);
|
|
safeSetItem(cacheKey, { schema: CACHE_SCHEMA, data: buckets });
|
|
updateWindowLRU(cacheKey);
|
|
}
|
|
|
|
export function clearSessionWindowCache(sessionId: string, userId?: number): void {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
window.localStorage.removeItem(getWindowCacheKey(sessionId, userId));
|
|
window.localStorage.removeItem(getWindowCacheKey(sessionId));
|
|
}
|
|
|
|
export function loadSessionDraft(sessionId: string): string {
|
|
if (typeof window === 'undefined' || !window.localStorage) return '';
|
|
return window.localStorage.getItem(getDraftCacheKey(sessionId)) || '';
|
|
}
|
|
|
|
export function saveSessionDraft(sessionId: string, draft: string): void {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
if (!draft.trim()) {
|
|
window.localStorage.removeItem(getDraftCacheKey(sessionId));
|
|
} else {
|
|
window.localStorage.setItem(getDraftCacheKey(sessionId), draft);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* migrateAndClearLegacyMessages: 检测并清除旧版全量 MESSAGES 键,释放 5MB 配额
|
|
*/
|
|
export async function migrateAndClearLegacyMessages(): Promise<void> {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
const raw = window.localStorage.getItem(STORAGE_KEYS.MESSAGES);
|
|
if (!raw) return;
|
|
|
|
try {
|
|
const parsed: ChatMessage[] = JSON.parse(raw);
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
// 尝试在 dev 环境下调用导入接口做一次性回填
|
|
await fetch('/api/v1/chat/messages/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(parsed),
|
|
}).catch(() => {});
|
|
}
|
|
} catch (_e) {
|
|
// 静默兜底
|
|
} finally {
|
|
window.localStorage.removeItem(STORAGE_KEYS.MESSAGES);
|
|
}
|
|
}
|
|
|
|
export function loadDeliverables(fallback: DeliverableItem[]): DeliverableItem[] {
|
|
const loaded = safeGetItem<DeliverableItem[]>(STORAGE_KEYS.DELIVERABLES, fallback);
|
|
const existingDocIds = new Set(loaded.map((d) => d.docId));
|
|
const missing = fallback.filter((d) => !existingDocIds.has(d.docId));
|
|
if (missing.length > 0) {
|
|
return [...loaded, ...missing];
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
export function saveDeliverables(deliverables: DeliverableItem[]): void {
|
|
// File/Blob 对象无法 JSON 序列化(会静默变成 {}),blob: URL 刷新后失效。
|
|
// 持久化时剔除这两项,避免 localStorage 存储损坏数据。
|
|
// 内存中的 deliverables react state 不受影响,当前会话内仍可正常预览。
|
|
const serializableDocs = deliverables.map((d) => {
|
|
if (!d.fileMeta) return d;
|
|
const { fileRaw, fileUrl, ...restFileMeta } = d.fileMeta;
|
|
const cleanedFileMeta = {
|
|
...restFileMeta,
|
|
// 仅保留非 blob: URL(如 /api/... 服务端 URL),页面刷新后依然有效
|
|
...(fileUrl && !fileUrl.startsWith('blob:') ? { fileUrl } : {}),
|
|
};
|
|
return { ...d, fileMeta: cleanedFileMeta };
|
|
});
|
|
safeSetItem(STORAGE_KEYS.DELIVERABLES, serializableDocs);
|
|
}
|
|
|
|
export function loadDocCache(): Record<string, string> {
|
|
return safeGetItem<Record<string, string>>(STORAGE_KEYS.DOC_CACHE, {});
|
|
}
|
|
|
|
export function saveDocCache(cache: Record<string, string>): void {
|
|
safeSetItem(STORAGE_KEYS.DOC_CACHE, cache);
|
|
}
|
|
|
|
export function loadRoles(fallback: RoleDefinition[]): RoleDefinition[] {
|
|
return safeGetItem<RoleDefinition[]>(STORAGE_KEYS.ROLES, fallback);
|
|
}
|
|
|
|
export function saveRoles(roles: RoleDefinition[]): void {
|
|
safeSetItem(STORAGE_KEYS.ROLES, roles);
|
|
}
|
|
|
|
export function loadUsers(fallback: UserProfile[]): UserProfile[] {
|
|
return safeGetItem<UserProfile[]>(STORAGE_KEYS.USERS, fallback);
|
|
}
|
|
|
|
export function saveUsers(users: UserProfile[]): void {
|
|
safeSetItem(STORAGE_KEYS.USERS, users);
|
|
}
|
|
|
|
/** localStorage 演示种子清理版本(幂等)。 */
|
|
export const DEMO_SEED_PURGE_VERSION = 'demo-seed-purge-v1';
|
|
const DEMO_SEED_PURGE_KEY = `${STORAGE_PREFIX}demo_seed_purge_version`;
|
|
|
|
/**
|
|
* 一次性剔除 localStorage 中的 demo seed 残留(交付物 / 会话 / 用户 / 项目)。
|
|
* 保留已服务端注册的真实项(非 isDemoSeedDocId,且非 demo-only projectId 的本地幻影会话)。
|
|
*/
|
|
export function purgeDemoSeedsFromLocalStorage(): { purged: boolean; version: string } {
|
|
if (typeof window === 'undefined' || !window.localStorage) {
|
|
return { purged: false, version: DEMO_SEED_PURGE_VERSION };
|
|
}
|
|
const current = window.localStorage.getItem(DEMO_SEED_PURGE_KEY);
|
|
if (current === DEMO_SEED_PURGE_VERSION) {
|
|
return { purged: false, version: DEMO_SEED_PURGE_VERSION };
|
|
}
|
|
|
|
try {
|
|
const deliverables = safeGetItem<DeliverableItem[]>(STORAGE_KEYS.DELIVERABLES, []);
|
|
const cleanedDeliverables = deliverables.filter((d) => !isDemoSeedDocId(d.docId));
|
|
safeSetItem(STORAGE_KEYS.DELIVERABLES, cleanedDeliverables);
|
|
|
|
const sessions = safeGetItem<ProjectSession[]>(STORAGE_KEYS.SESSIONS, []);
|
|
const cleanedSessions = sessions.filter((s) => !isDemoSeedProjectId(s.projectId));
|
|
safeSetItem(STORAGE_KEYS.SESSIONS, cleanedSessions);
|
|
|
|
const active = window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || '';
|
|
if (active && cleanedSessions.every((s) => s.sessionId !== active)) {
|
|
window.localStorage.removeItem(STORAGE_KEYS.ACTIVE_SESSION);
|
|
}
|
|
|
|
// Demo MOCK_USERS 特征:张工/李主管/王总监 + 固定 employeeId
|
|
const users = safeGetItem<UserProfile[]>(STORAGE_KEYS.USERS, []);
|
|
const cleanedUsers = users.filter(
|
|
(u) =>
|
|
!(
|
|
(u.employeeId === 'SMDF-0821' && u.name === '张工') ||
|
|
(u.employeeId === 'SMDF-0205' && u.name === '李主管') ||
|
|
(u.employeeId === 'SMDF-0018' && u.name === '王总监') ||
|
|
(u.employeeId === 'SYS-000' && u.name === '系统管理员') ||
|
|
(u.employeeId === 'EXT-999' && u.name === '访客')
|
|
),
|
|
);
|
|
safeSetItem(STORAGE_KEYS.USERS, cleanedUsers);
|
|
|
|
try {
|
|
const rawProjects = window.localStorage.getItem('smdf_agent_workbench_v6_projects');
|
|
if (rawProjects) {
|
|
const projects = JSON.parse(rawProjects) as { id: number }[];
|
|
if (Array.isArray(projects)) {
|
|
const cleaned = projects.filter((p) => !isDemoSeedProjectId(p.id));
|
|
window.localStorage.setItem('smdf_agent_workbench_v6_projects', JSON.stringify(cleaned));
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore project purge errors
|
|
}
|
|
} finally {
|
|
window.localStorage.setItem(DEMO_SEED_PURGE_KEY, DEMO_SEED_PURGE_VERSION);
|
|
}
|
|
|
|
return { purged: true, version: DEMO_SEED_PURGE_VERSION };
|
|
}
|
|
|
|
/**
|
|
* resetWorkbenchStorage: 清空工作台缓存,恢复系统默认体验状态
|
|
*/
|
|
export function resetWorkbenchStorage(): void {
|
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
|
Object.values(STORAGE_KEYS).forEach((k) => window.localStorage.removeItem(k));
|
|
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
|
|
}
|