Files
Agent-System/web/src/data/storage.ts
T
weichenandCursor fc32ba3dc3 fix(web): version chat window cache and cover cross-user session list
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>
2026-09-19 09:28:11 +00:00

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);
}