fix(chat): make cloud the only truth for charts, synastry, and pin/archive
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Local fallbacks were creating fake saves and resurrecting deleted rows.
Pin and archive now live on chat_sessions so they follow the account.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 21:38:21 +08:00
co-authored by Cursor
parent 31c7b48261
commit ce6a8a7e72
16 changed files with 390 additions and 153 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
type RouteContext = { params: Promise<{ id: string }> };
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
+1 -1
View File
@@ -4,7 +4,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
export async function GET() {
try {
+159 -123
View File
@@ -106,7 +106,11 @@ import {
membershipHref,
} from "@/lib/membership";
import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { chartLibrarySessionBranch } from "@/lib/chart-library-session";
import {
chartLibraryFromCloudOthers,
chartLibraryOnCloudFailure,
chartLibrarySessionBranch,
} from "@/lib/chart-library-session";
import {
emptyConsultationTimeline,
reduceConsultationTimeline,
@@ -249,6 +253,8 @@ type ChatSession = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
pinned: boolean;
archivedAt: string | null;
messagesHydrated: boolean;
};
@@ -448,6 +454,8 @@ function createSession(
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
pinned: false,
archivedAt: null,
messagesHydrated: true,
...chartBinding,
};
@@ -497,19 +505,62 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
};
}
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
function activeChartStorageKey(accountId: string) {
return `jyotisha_active_chart:${accountId}`;
}
function synastryHistoryStorageKey(accountId: string) {
return `jyotisha_synastry_history:${accountId}`;
}
function dailyStarlanguageStorageKey(accountId: string) {
return `jyotisha_daily_starlanguage:${accountId}`;
}
function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") {
return `jyotisha-session-controls:${accountId}:${kind}`;
}
function discardLegacyCloudMirrorKeys(accountId: string) {
localStorage.removeItem(`jyotisha_chart_library:${accountId}`);
localStorage.removeItem(`jyotisha_synastry_history:${accountId}`);
}
function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown;
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
function clearLegacySessionControlKeys(accountId: string) {
localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned"));
localStorage.removeItem(sessionControlsStorageKey(accountId, "archived"));
}
function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] {
const pinnedKey = sessionControlsStorageKey(accountId, "pinned");
const archivedKey = sessionControlsStorageKey(accountId, "archived");
if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) {
return sessions;
}
const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned"));
const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived"));
const next = sessions.map((session) => ({
...session,
pinned: session.pinned || pinnedIds.has(session.id),
archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null),
}));
void Promise.allSettled(next.flatMap((session, index) => {
const previous = sessions[index];
if (!previous) return [];
const patch: { pinned?: boolean; archived_at?: string | null } = {};
if (session.pinned !== previous.pinned) patch.pinned = session.pinned;
if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt;
if (patch.pinned === undefined && patch.archived_at === undefined) return [];
return [writeChatSession(session.id, patch, "update")];
}));
clearLegacySessionControlKeys(accountId);
return next;
}
type StoredDailyStarlanguage = {
readonly day: string;
readonly fingerprint: string;
@@ -574,34 +625,6 @@ function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
return [buildSelfChartRecord(profile), ...others];
}
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
try {
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
return Array.isArray(parsed)
? parsed
.filter((record) => record?.id && record?.profile)
.map((record) => {
const relationship = record.role === "self" ? "self" : record.relationship || record.profile.chartRelationship || "other";
return { ...record, profile: { ...record.profile, chartRelationship: relationship }, relationship };
})
: [];
} catch {
return [];
}
}
function readSynastryHistory(accountId: string): SynastryReportCard[] {
try {
const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : [];
} catch {
return [];
}
}
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
}
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
if (!record.report || typeof record.report !== "object") return null;
return {
@@ -903,6 +926,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
chart_profile_role?: unknown;
session_type?: unknown;
updated_at?: unknown;
archived_at?: unknown;
};
const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages");
const messages: Message[] = Array.isArray(session.messages)
@@ -951,6 +975,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other"
? session.chart_profile_role
: null,
pinned: session.pinned === true,
archivedAt: typeof session.archived_at === "string" && session.archived_at
? session.archived_at
: typeof session.archivedAt === "string" && session.archivedAt
? session.archivedAt
: null,
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
@@ -1286,8 +1316,6 @@ export default function Home() {
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
@@ -1391,12 +1419,12 @@ export default function Home() {
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
.filter((session) => showArchivedSessions ? Boolean(session.archivedAt) : !session.archivedAt)
.filter((session) => session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated
|| session.id === activeSessionId)
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
.sort((left, right) => Number(right.pinned) - Number(left.pinned));
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated
@@ -1478,13 +1506,6 @@ export default function Home() {
rectificationSessionId,
]);
useEffect(() => {
if (!hydrated || !accountId) return;
const prefix = `jyotisha-session-controls:${accountId}:`;
setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]"));
setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]"));
}, [accountId, hydrated]);
useEffect(() => {
if (!hydrated || !accountId) return;
void (async () => {
@@ -1499,13 +1520,6 @@ export default function Home() {
})();
}, [accountId, hydrated]);
useEffect(() => {
if (!hydrated || !accountId) return;
const prefix = `jyotisha-session-controls:${accountId}:`;
localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds));
localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds));
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
useEffect(() => {
if (!hydrated || !accountId) return;
setActiveChartId(localStorage.getItem(activeChartStorageKey(accountId)) || "self");
@@ -1523,37 +1537,47 @@ export default function Home() {
const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile;
if (branch === "hydrate-then-persist") {
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profileForLibrary));
setSynastryHistory(readSynastryHistory(accountId));
discardLegacyCloudMirrorKeys(accountId);
setChartLibrary(chartLibraryOnCloudFailure(profileForLibrary, upsertSelfChart));
setSynastryHistory([]);
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(() => {
const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
setComposerNotice("星盘库暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart));
})
.catch(() => {
setComposerNotice("星盘库暂时无法读取,请重试。");
});
},
});
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
setComposerNotice("合盘历史暂时无法读取,请重试。", {
label: "重试",
onClick: () => {
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10));
})
.catch(() => {
setComposerNotice("合盘历史暂时无法读取,请重试。");
});
},
});
});
}
setChartLibrary((current) => {
const next = upsertSelfChart(current, profileForLibrary);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setChartLibrary((current) => upsertSelfChart(current, profileForLibrary));
}, [account, accountId, activeChartId, profile]);
useEffect(() => {
@@ -1786,6 +1810,8 @@ export default function Home() {
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
pinned: false,
archivedAt: null,
messagesHydrated: true,
};
setAccount({
@@ -1857,6 +1883,7 @@ export default function Home() {
}
nextSessions = [initialSession];
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = null;
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
@@ -2377,8 +2404,6 @@ export default function Home() {
const nextSessions = sessions.filter((item) => item.id !== session.id);
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
setPinnedSessionIds((current) => current.filter((id) => id !== session.id));
setArchivedSessionIds((current) => current.filter((id) => id !== session.id));
if (activeSessionId === session.id) {
const fallbackId = nextSessions[0]?.id ?? "";
setActiveSessionId(fallbackId);
@@ -2395,18 +2420,37 @@ export default function Home() {
}
function togglePinnedSession(sessionId: string) {
setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const nextPinned = !session.pinned;
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
});
}
function toggleArchivedSession(sessionId: string) {
const restoring = archivedSessionIds.includes(sessionId);
setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const restoring = Boolean(session.archivedAt);
const previousActiveId = activeSessionId;
const nextArchivedAt = restoring ? null : new Date().toISOString();
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
if (!restoring && activeSessionId === sessionId) {
const fallbackId = visibleSessions.find((session) => session.id !== sessionId)?.id ?? "";
const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
if (!restoring && previousActiveId === sessionId) {
setActiveSessionId(previousActiveId);
if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace");
}
setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败");
});
}
async function shareSession(session: ChatSession) {
@@ -2680,43 +2724,32 @@ export default function Home() {
return;
}
if (!accountId) return;
let record: ChartLibraryRecord = {
const record: ChartLibraryRecord = {
id: editingChartId || globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
relationship: otherChartRelationship,
updatedAt: timestamp(),
};
let cloudSaved = false;
try {
if (editingChartId) {
record = await updateCloudChartProfile(record);
} else {
record = await saveCloudChartProfile(record);
cloudSaved = true;
}
cloudSaved = true;
} catch {
setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。");
const saved = editingChartId
? await updateCloudChartProfile(record)
: await saveCloudChartProfile(record);
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === saved.id ? saved : item)
: [...current, saved];
return upsertSelfChart(others, selfProfile);
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
}
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === record.id ? record : item)
: [...current, record];
const next = upsertSelfChart(others, selfProfile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
if (cloudSaved) {
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
} else {
setProfileNotice("保存到本地星盘库。");
} catch {
setProfileNotice("保存失败,请重试");
setAccountError("");
}
}
@@ -2731,12 +2764,12 @@ export default function Home() {
async function deleteOtherChart(recordId: string) {
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
let cloudDeleted = false;
try {
await deleteCloudChartProfile(recordId);
cloudDeleted = true;
} catch {
setProfileNotice("删除失败,请重试");
setAccountError("");
return;
}
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
@@ -2744,13 +2777,10 @@ export default function Home() {
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(accountId), "self");
}
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setAccountError("");
setProfileNotice(cloudDeleted
? "已从云端星盘库删除。"
: "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。");
setProfileNotice("已从云端星盘库删除。");
}
function makeDefaultChart(record: ChartLibraryRecord) {
@@ -3073,6 +3103,7 @@ export default function Home() {
// Merge the server-created session into the local list. The browser
// never generates a Case id; it only mirrors the returned binding.
const existing = sessions.find((session) => session.id === opened.sessionId);
const merged: ChatSession = {
id: opened.sessionId,
title: resolveSessionTitle("生时校正", undefined, {
@@ -3085,6 +3116,8 @@ export default function Home() {
updatedAt: timestamp(),
sessionType: "birth_time_rectification",
rectificationCaseId: opened.caseId,
pinned: existing?.pinned ?? false,
archivedAt: existing?.archivedAt ?? null,
messagesHydrated: true,
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
@@ -3197,20 +3230,20 @@ export default function Home() {
createdAt: Date.now(),
};
let savedReportCard = reportCard;
let historyPersisted = !accountId;
if (accountId) {
try {
savedReportCard = await saveCloudSynastryReport(reportCard);
historyPersisted = true;
} catch {
// Local history remains the fallback when cloud persistence is unavailable.
historyPersisted = false;
}
}
setSynastryReportCard(savedReportCard);
if (accountId) {
setSynastryHistory((current) => {
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
if (accountId && historyPersisted) {
setSynastryHistory((current) => (
[savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10)
));
}
chooseSuggestedQuestion([
baseQuestion,
@@ -3218,6 +3251,9 @@ export default function Home() {
evidenceSummary,
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
].join("\n"), relationshipType === "business" ? "career" : "marriage");
if (accountId && !historyPersisted) {
setComposerNotice("未能存入历史");
}
} else {
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice(response.status === 404
@@ -4108,8 +4144,8 @@ export default function Home() {
const sidebarSessions = visibleSessions.map((session) => ({
id: session.id,
title: sessionSidebarTitle(session, chartLibrary),
pinned: pinnedSessionIds.includes(session.id),
archived: archivedSessionIds.includes(session.id),
pinned: session.pinned,
archived: Boolean(session.archivedAt),
}));
const sidebarCharts = (chartLibrary.length > 0
? chartLibrary
@@ -4354,7 +4390,7 @@ export default function Home() {
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: archivedSessionIds.length,
archivedCount: sessions.filter((session) => session.archivedAt).length,
showingArchived: showArchivedSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,