fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { SessionListProvider, sessionListSidebarModel, useSessionList } from "@/hooks/use-session-list";
|
||||
import "../site-styles";
|
||||
|
||||
function AppShell({ children }: { children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const { sessions, account } = sessionListSidebarModel(list);
|
||||
const registration = list.registration;
|
||||
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
<main className="chat-app">
|
||||
<AppSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={registration?.activeSessionId ?? null}
|
||||
openingSessionId={registration?.openingSessionId ?? null}
|
||||
openErrorSessionId={registration?.openErrorSessionId ?? null}
|
||||
openErrorMessage={registration?.openErrorMessage ?? ""}
|
||||
account={account}
|
||||
settled={list.settled}
|
||||
signedOut={list.signedOut}
|
||||
controls={registration?.controls}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={registration?.insetClassName ?? "chat-panel secondary-panel"}
|
||||
inert={registration?.insetInert}
|
||||
>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SessionListProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</SessionListProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import "@/app/site-styles";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Sparkles } from "lucide-react";
|
||||
@@ -9,7 +8,6 @@ import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
import { ProfilePanel } from "@/components/profile-panel";
|
||||
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import {
|
||||
BirthTimeAssessmentOverlay,
|
||||
type BirthTimeAssessmentPhase,
|
||||
@@ -41,7 +39,9 @@ import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"
|
||||
import { ChatComposer } from "@/components/chat-composer";
|
||||
import { RectificationComposerReopen } from "@/components/rectification-composer-reopen";
|
||||
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
@@ -186,7 +186,7 @@ import {
|
||||
placeQuestion,
|
||||
readProfile,
|
||||
selectedBirthPlace,
|
||||
sessionChartLabel, sessionSidebarSubtitle, sessionSidebarTitle, upsertSelfChart,
|
||||
sessionChartLabel, upsertSelfChart,
|
||||
} from "@/lib/home-profile";
|
||||
import {
|
||||
activeChartStorageKey,
|
||||
@@ -196,7 +196,6 @@ import {
|
||||
ConsultationStatusError,
|
||||
createSession,
|
||||
discardLegacyCloudMirrorKeys,
|
||||
fetchAccount,
|
||||
fetchActiveConsultationStatus,
|
||||
fetchCloudChartLibrary,
|
||||
fetchCloudSynastryHistory,
|
||||
@@ -204,7 +203,6 @@ import {
|
||||
fetchDailyStarlanguage,
|
||||
fetchModelCatalog,
|
||||
fetchSessionDetail,
|
||||
fetchSessions,
|
||||
friendlyError,
|
||||
LoginRedirectError,
|
||||
mergeHydratedSession,
|
||||
@@ -212,6 +210,7 @@ import {
|
||||
resolveLookupBootstrap,
|
||||
payloadCode,
|
||||
payloadMessage,
|
||||
readDraftConsultation,
|
||||
readSessions,
|
||||
readStoredDailyStarlanguage,
|
||||
readStoredPendingConsultation,
|
||||
@@ -229,6 +228,7 @@ import {
|
||||
starterHomeLandingNeedsConsultation,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
@@ -243,16 +243,24 @@ const BillingPanel = dynamic(
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const sessionList = useSessionList();
|
||||
const {
|
||||
sessions,
|
||||
setSessions,
|
||||
account,
|
||||
setAccount,
|
||||
ready: sessionListReady,
|
||||
boot: sessionListBoot,
|
||||
registerShellControls,
|
||||
} = sessionList;
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
|
||||
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
|
||||
const [activeChartId, setActiveChartId] = useState("self");
|
||||
const [billingPane, setBillingPane] = useState<OpenAccountDialogOptions>({});
|
||||
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [accountError, setAccountError] = useState("");
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const draftTheme = useRef<Theme | null>(null);
|
||||
@@ -903,21 +911,29 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
|
||||
fetchAccount(controller.signal),
|
||||
fetchModelCatalog(controller.signal)
|
||||
.then((catalog) => ({ catalog, unavailable: false }))
|
||||
.catch((caught: unknown) => {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
return { catalog: null, unavailable: true };
|
||||
}),
|
||||
fetchSessions(controller.signal),
|
||||
]);
|
||||
await sessionListReady;
|
||||
if (controller.signal.aborted) return;
|
||||
const listBoot = sessionListBoot();
|
||||
if (listBoot?.signedOut || !listBoot?.account) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const nextAccount = listBoot.account;
|
||||
const modelCatalogResult = await fetchModelCatalog(controller.signal)
|
||||
.then((catalog) => ({ catalog, unavailable: false }))
|
||||
.catch((caught: unknown) => {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
return { catalog: null, unavailable: true };
|
||||
});
|
||||
const nextModelCatalog = modelCatalogResult.catalog;
|
||||
const nextProfile = readProfile(nextAccount.profile);
|
||||
const parsedSessions = readSessions(sessionsPayload.sessions, nextModelCatalog);
|
||||
setSessionsCursor(sessionsPayload.nextCursor);
|
||||
let nextSessions = parsedSessions.sessions;
|
||||
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
|
||||
setSessionsCursor(listBoot.cursor);
|
||||
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
|
||||
const draftSession = readDraftConsultation(listBoot.draftRow, nextModelCatalog);
|
||||
if (draftSession && !nextSessions.some((session) => session.id === draftSession.id)) {
|
||||
nextSessions = [...nextSessions, draftSession];
|
||||
}
|
||||
if (nextSessions.length === 0) {
|
||||
if (controller.signal.aborted) return;
|
||||
const initialSession = createSession(
|
||||
@@ -993,27 +1009,32 @@ export default function Home() {
|
||||
bootstrapSelection.urlAction,
|
||||
);
|
||||
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
|
||||
if (controller.signal.aborted) return;
|
||||
const homeSession = createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
if (nextModelCatalog) {
|
||||
await writeChatSession(homeSession.id, {
|
||||
title: homeSession.title,
|
||||
theme: homeSession.theme,
|
||||
model_id: homeSession.modelId,
|
||||
messages: [],
|
||||
session_type: homeSession.sessionType,
|
||||
rectification_case_id: homeSession.rectificationCaseId,
|
||||
chart_profile_id: homeSession.chartProfileId,
|
||||
chart_profile_name: homeSession.chartProfileName,
|
||||
chart_profile_role: homeSession.chartProfileRole,
|
||||
}, "create");
|
||||
const reusable = findReusableEmptyConsultation(nextSessions);
|
||||
if (reusable) {
|
||||
landingSessionId = reusable.id;
|
||||
} else {
|
||||
if (controller.signal.aborted) return;
|
||||
const homeSession = createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
if (nextModelCatalog) {
|
||||
await writeChatSession(homeSession.id, {
|
||||
title: homeSession.title,
|
||||
theme: homeSession.theme,
|
||||
model_id: homeSession.modelId,
|
||||
messages: [],
|
||||
session_type: homeSession.sessionType,
|
||||
rectification_case_id: homeSession.rectificationCaseId,
|
||||
chart_profile_id: homeSession.chartProfileId,
|
||||
chart_profile_name: homeSession.chartProfileName,
|
||||
chart_profile_role: homeSession.chartProfileRole,
|
||||
}, "create");
|
||||
}
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
|
||||
?? nextSessions[0];
|
||||
@@ -1453,6 +1474,44 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen;
|
||||
useHomeShellRegistration({
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTrigger,
|
||||
activeSessionId: activeSession?.id,
|
||||
cancellationPending,
|
||||
chartLibrary,
|
||||
creatingSession,
|
||||
hydrated,
|
||||
loadMoreSessions,
|
||||
modalOpen,
|
||||
modelCatalog,
|
||||
openAccountDialog,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
rectificationErrorMessage,
|
||||
rectificationErrorSessionId,
|
||||
rectificationOpeningSessionId,
|
||||
rectificationSurfaceOpen,
|
||||
registerShellControls,
|
||||
renameSession,
|
||||
selectSession,
|
||||
sessionMenuId,
|
||||
sessions,
|
||||
sessionsCursor,
|
||||
setAccountMenuOpen,
|
||||
setPendingSessionDeletion,
|
||||
setSessionMenuId,
|
||||
shareSession,
|
||||
showArchivedSessions,
|
||||
startNewChat,
|
||||
toggleArchivedSession,
|
||||
toggleArchivedView,
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
});
|
||||
|
||||
if (!hydrated || (!account && !accountError)) {
|
||||
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
|
||||
return (
|
||||
@@ -1477,23 +1536,6 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarAccount = {
|
||||
name: profile.name.trim() || account.user.email || "账户",
|
||||
email: account.user.email || "尚未读取邮箱",
|
||||
credits: account.credits,
|
||||
avatar: account.avatar,
|
||||
initial: profile.name.trim().slice(0, 1)
|
||||
|| account.user.email?.slice(0, 1).toUpperCase()
|
||||
|| "你",
|
||||
};
|
||||
|
||||
const sidebarSessions = visibleSessions.map((session) => ({
|
||||
id: session.id, title: sessionSidebarTitle(session, chartLibrary),
|
||||
subtitle: sessionSidebarSubtitle(session, chartLibrary),
|
||||
pinned: session.pinned, archived: Boolean(session.archivedAt), updatedAt: session.updatedAt,
|
||||
}));
|
||||
const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen;
|
||||
|
||||
const accountOverlayModel: AccountOverlayModel | null = activeAccountDialog === null ? null : {
|
||||
title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "",
|
||||
dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "",
|
||||
@@ -1541,53 +1583,8 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
|
||||
<main className="chat-app">
|
||||
<>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
<AppSidebar
|
||||
sessions={sidebarSessions}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
openingSessionId={rectificationOpeningSessionId}
|
||||
openErrorSessionId={rectificationErrorSessionId}
|
||||
openErrorMessage={rectificationErrorSessionId ? rectificationErrorMessage : ""}
|
||||
account={sidebarAccount}
|
||||
controls={{
|
||||
newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending,
|
||||
creatingSession,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions,
|
||||
hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
},
|
||||
},
|
||||
onAccountMenuOpenChange: setAccountMenuOpen,
|
||||
onNewChat: () => void startNewChat(),
|
||||
onSelectSession: selectSession,
|
||||
onOpenProfile: () => openAccountDialog("profile"),
|
||||
onOpenChartLibrary: () => openAccountDialog("chart-library"),
|
||||
onOpenGeneral: () => openAccountDialog("general"),
|
||||
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
|
||||
onOpenLogout: () => openAccountDialog("logout"),
|
||||
}}
|
||||
/>
|
||||
{pendingSessionDeletion ? (
|
||||
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
|
||||
<section className="account-modal logout-modal session-delete-confirmation" role="alertdialog" aria-modal="true" aria-label="确认删除聊天记录" onMouseDown={(event) => event.stopPropagation()}>
|
||||
@@ -1604,7 +1601,6 @@ export default function Home() {
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<SidebarInset className={`chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`} inert={modalOpen}>
|
||||
<header className="chat-header">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<div className="chat-header-title">
|
||||
@@ -1806,7 +1802,6 @@ export default function Home() {
|
||||
{starterHomeVisible && !rectificationSurfaceOpen && !onboardingFormActive
|
||||
? <div className="starter-balance" aria-hidden="true" />
|
||||
: null}
|
||||
</SidebarInset>
|
||||
|
||||
<AccountDialogOverlay
|
||||
open={activeAccountDialog !== null}
|
||||
@@ -1824,7 +1819,6 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import "../site-styles";
|
||||
|
||||
/**
|
||||
* One shell for `/chart`, `/ephemeris`, `/reports` and `/reports/[reportId]`.
|
||||
*
|
||||
* A route group changes no URL: the four routes keep their paths and their own
|
||||
* `metadata` and `dynamic` declarations. What it changes is who owns the shell.
|
||||
* Each page used to render `SecondaryShell`, so each carried a provider and a
|
||||
* sidebar of its own; stepping between two of them tore the nav down and built
|
||||
* it again, list request included. Hoisting it here means the sidebar is mounted
|
||||
* once per visit to this section, and `useSidebarData` runs once with it.
|
||||
*
|
||||
* The sidebar is read-only on purpose — no renaming, archiving, deleting or
|
||||
* account menu. Those are backed by `Home()`'s optimistic-update and rollback
|
||||
* layer, and none of these four pages offers session management
|
||||
* (TASK-cend-surfaces-claude-alignment-20260916 D9, carried forward by
|
||||
* TASK-sidebar-unify-20260916 D1).
|
||||
*/
|
||||
export default function SecondaryLayout({ children }: { children: ReactNode }) {
|
||||
const { sessions, account, settled, signedOut } = useSidebarData();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<main className="chat-app">
|
||||
<AppSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={null}
|
||||
account={account}
|
||||
settled={settled}
|
||||
signedOut={signedOut}
|
||||
/>
|
||||
<SidebarInset className="chat-panel secondary-panel">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { metadataUpdateValues } from "@/lib/session-metadata-update";
|
||||
|
||||
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,pinned,archived_at";
|
||||
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,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 } {
|
||||
|
||||
@@ -16,8 +16,15 @@ import {
|
||||
parseSessionCursor,
|
||||
sessionCursorFilter,
|
||||
} from "@/lib/session-cursor";
|
||||
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_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";
|
||||
function excludeEmptyConsultations<Query extends {
|
||||
not: (column: string, operator: string, value: unknown) => Query;
|
||||
}>(query: Query): Query {
|
||||
// Empty consultations are `messages = []`. Rectification rows keep an opening
|
||||
// turn, so excluding empty arrays leaves them in the list (BUG-928).
|
||||
return query.not("messages", "eq", []);
|
||||
}
|
||||
|
||||
function applyArchiveFilter<Query extends {
|
||||
is: (column: string, value: null) => Query;
|
||||
@@ -40,13 +47,15 @@ export async function GET(request: Request) {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
let pageQuery = applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", false),
|
||||
archived,
|
||||
let pageQuery = excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", false),
|
||||
archived,
|
||||
),
|
||||
)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false })
|
||||
@@ -58,25 +67,42 @@ export async function GET(request: Request) {
|
||||
if (pageError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
|
||||
let pinnedRows: typeof pageRows = [];
|
||||
let draft: NonNullable<typeof pageRows>[number] | null = null;
|
||||
if (!cursor) {
|
||||
const { data: pinnedData, error: pinnedError } = await applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", true),
|
||||
archived,
|
||||
const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("pinned", true),
|
||||
archived,
|
||||
),
|
||||
)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false });
|
||||
if (pinnedError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
pinnedRows = pinnedData ?? [];
|
||||
if (!archived) {
|
||||
const { data: draftData, error: draftError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("session_type", "consultation")
|
||||
.eq("messages", [])
|
||||
.is("archived_at", null)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false })
|
||||
.limit(1);
|
||||
if (draftError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
draft = draftData?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextCursor = nextSessionCursor(pageRows ?? [], limit);
|
||||
const page = (pageRows ?? []).slice(0, limit);
|
||||
const sessions = cursor ? page : [...(pinnedRows ?? []), ...page];
|
||||
return NextResponse.json({ sessions, nextCursor });
|
||||
return NextResponse.json({ sessions, nextCursor, draft });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
import type { ChatSession } from "@/lib/home-types";
|
||||
import type { Account } from "@/lib/home-types";
|
||||
import type { ChartLibraryRecord, Profile } from "@/lib/home-types";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
|
||||
import type { ShellRegistration } from "@/lib/session-list-context";
|
||||
|
||||
type AccountDialogName = "profile" | "chart-library" | "general" | "billing" | "logout";
|
||||
|
||||
type HomeShellRegistrationParams = {
|
||||
account: Account | null;
|
||||
accountMenuOpen: boolean;
|
||||
accountTrigger: RefObject<HTMLButtonElement | null>;
|
||||
activeSessionId: string | undefined;
|
||||
cancellationPending: boolean;
|
||||
chartLibrary: readonly ChartLibraryRecord[];
|
||||
creatingSession: boolean;
|
||||
hydrated: boolean;
|
||||
loadMoreSessions: () => void;
|
||||
modalOpen: boolean;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
|
||||
pendingSessionId: string | null;
|
||||
profile: Profile;
|
||||
rectificationErrorMessage: string;
|
||||
rectificationErrorSessionId: string | null;
|
||||
rectificationOpeningSessionId: string | null;
|
||||
rectificationSurfaceOpen: boolean;
|
||||
registerShellControls: (registration: ShellRegistration | null) => void;
|
||||
renameSession: (session: ChatSession) => void | Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
sessionMenuId: string | null;
|
||||
sessions: readonly ChatSession[];
|
||||
sessionsCursor: string | null;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
showArchivedSessions: boolean;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
toggleArchivedSession: (sessionId: string) => void;
|
||||
toggleArchivedView: () => void | Promise<void>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
const {
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTrigger,
|
||||
activeSessionId,
|
||||
cancellationPending,
|
||||
chartLibrary,
|
||||
creatingSession,
|
||||
hydrated,
|
||||
loadMoreSessions,
|
||||
modalOpen,
|
||||
modelCatalog,
|
||||
openAccountDialog,
|
||||
pendingSessionId,
|
||||
profile,
|
||||
rectificationErrorMessage,
|
||||
rectificationErrorSessionId,
|
||||
rectificationOpeningSessionId,
|
||||
rectificationSurfaceOpen,
|
||||
registerShellControls,
|
||||
renameSession,
|
||||
selectSession,
|
||||
sessionMenuId,
|
||||
sessions,
|
||||
sessionsCursor,
|
||||
setAccountMenuOpen,
|
||||
setPendingSessionDeletion,
|
||||
setSessionMenuId,
|
||||
shareSession,
|
||||
showArchivedSessions,
|
||||
startNewChat,
|
||||
toggleArchivedSession,
|
||||
toggleArchivedView,
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
} = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !account) {
|
||||
registerShellControls(null);
|
||||
return;
|
||||
}
|
||||
const name = profile.name.trim();
|
||||
const email = account.user.email || "尚未读取邮箱";
|
||||
registerShellControls({
|
||||
escapeBlocked: accountMenuOpen || modalOpen,
|
||||
activeSessionId: activeSessionId ?? null,
|
||||
openingSessionId: rectificationOpeningSessionId,
|
||||
openErrorSessionId: rectificationErrorSessionId,
|
||||
openErrorMessage: rectificationErrorSessionId ? rectificationErrorMessage : "",
|
||||
insetClassName: `chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`,
|
||||
insetInert: modalOpen,
|
||||
sidebarAccount: {
|
||||
name: name || account.user.email || "账户",
|
||||
email,
|
||||
credits: account.credits,
|
||||
avatar: account.avatar,
|
||||
initial: name.slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你",
|
||||
},
|
||||
sidebarSessions: visibleSessions.map((session) => toSidebarSessionRow(session, chartLibrary)),
|
||||
controls: {
|
||||
newChatDisabled: !hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending,
|
||||
creatingSession,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
archivedCount: sessions.filter((session) => session.archivedAt).length,
|
||||
showingArchived: showArchivedSessions,
|
||||
hasMore: Boolean(sessionsCursor),
|
||||
onLoadMore: loadMoreSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); },
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
},
|
||||
},
|
||||
onAccountMenuOpenChange: setAccountMenuOpen,
|
||||
onNewChat: () => { void startNewChat(); },
|
||||
onSelectSession: selectSession,
|
||||
onOpenProfile: () => openAccountDialog("profile"),
|
||||
onOpenChartLibrary: () => openAccountDialog("chart-library"),
|
||||
onOpenGeneral: () => openAccountDialog("general"),
|
||||
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
|
||||
onOpenLogout: () => openAccountDialog("logout"),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending,
|
||||
chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog,
|
||||
openAccountDialog, pendingSessionId, profile.name, rectificationErrorMessage,
|
||||
rectificationErrorSessionId, rectificationOpeningSessionId, rectificationSurfaceOpen,
|
||||
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
|
||||
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
|
||||
shareSession, showArchivedSessions, startNewChat, toggleArchivedSession,
|
||||
toggleArchivedView, togglePinnedSession, visibleSessions,
|
||||
]);
|
||||
|
||||
useEffect(() => () => registerShellControls(null), [registerShellControls]);
|
||||
}
|
||||
@@ -277,6 +277,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
theme: "general",
|
||||
modelId: modelCatalog.defaultModelId ?? "",
|
||||
messages: [],
|
||||
createdAt: existing?.createdAt,
|
||||
updatedAt: openIdentity.updatedAt,
|
||||
sessionType: "birth_time_rectification",
|
||||
rectificationCaseId: opened.caseId,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SessionListProvider, useSessionList, sessionListSidebarModel } from "@/lib/session-list-context";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
|
||||
import { sortSessions } from "@/lib/session-groups";
|
||||
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
|
||||
import {
|
||||
clearBirthTimeConsultationConsent,
|
||||
type BirthTimeConsultationConsentState,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
patchSessionModel,
|
||||
readSessions,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import { chartSnapshotForSession } from "@/lib/home-profile";
|
||||
import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
@@ -218,12 +218,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
updateSession(session.id, () => nextSession);
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
/* The secondary pages read their list from a 60s module cache; without
|
||||
this, walking to /chart right after a rename still shows the old
|
||||
title. Invalidate rather than write through: `Home()` owns the richer
|
||||
ChatSession shape, and one refetch is cheaper than keeping two
|
||||
representations in step. */
|
||||
invalidateSidebarCache();
|
||||
} catch (caught) {
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
|
||||
}
|
||||
@@ -242,7 +236,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||
if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败");
|
||||
invalidateSidebarCache();
|
||||
} catch (caught) {
|
||||
setSessions(previousSessions);
|
||||
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
|
||||
@@ -254,7 +247,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!session) return;
|
||||
const nextPinned = !session.pinned;
|
||||
updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned }));
|
||||
void writeChatSession(sessionId, { pinned: nextPinned }, "update").then(invalidateSidebarCache).catch((caught) => {
|
||||
void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => {
|
||||
updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned }));
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败");
|
||||
});
|
||||
@@ -271,7 +264,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
activateFallbackSession(visibleSessions.filter((item) => item.id !== sessionId));
|
||||
}
|
||||
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
|
||||
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
|
||||
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
|
||||
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
|
||||
if (!restoring && previousActiveId === sessionId) {
|
||||
setActiveSessionId(previousActiveId);
|
||||
@@ -312,6 +305,19 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
|
||||
if (!account || !modelCatalog || creatingSession) return null;
|
||||
if (!options?.continuedFromSessionId) {
|
||||
const reusable = findReusableEmptyConsultation(sessions);
|
||||
if (reusable) {
|
||||
setActiveSessionId(reusable.id);
|
||||
if (!uiPreview.current) writeSessionUrl(reusable.id, "push");
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
return reusable;
|
||||
}
|
||||
}
|
||||
const nextSession = {
|
||||
...createSession(modelCatalog.defaultModelId),
|
||||
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
||||
@@ -335,7 +341,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
? { continuedFromSessionId: options.continuedFromSessionId }
|
||||
: undefined,
|
||||
);
|
||||
invalidateSidebarCache();
|
||||
return nextSession;
|
||||
} catch (caught) {
|
||||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
import type { BeamAvatar } from "@/lib/beam-avatar";
|
||||
import {
|
||||
invalidateSidebarCache,
|
||||
readSidebarCache,
|
||||
sidebarCacheIsFresh,
|
||||
writeSidebarCache,
|
||||
} from "@/lib/sidebar-data-cache";
|
||||
|
||||
/**
|
||||
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
|
||||
*
|
||||
* The chat page's sidebar is fed by `Home()`'s hooks, which also own renaming,
|
||||
* deleting, pinning and archiving — optimistic updates, confirmation dialogs and
|
||||
* rollback included. Lifting that whole layer so four routes can share it is far
|
||||
* more than these pages need: none of them offers session management. Two GETs
|
||||
* are enough, and this hook never writes.
|
||||
*
|
||||
* It is called once, from the `(secondary)` layout, so moving between those
|
||||
* four routes does not re-run it at all. The module cache behind it covers the
|
||||
* other trip: leaving for `/` and coming back unmounts the layout, and without
|
||||
* it that would mean two more requests inside the same minute.
|
||||
*/
|
||||
|
||||
export type SidebarDataState = {
|
||||
readonly sessions: readonly SidebarSession[];
|
||||
readonly account: SidebarAccount | null;
|
||||
/** True once both requests have settled, however they settled. */
|
||||
readonly settled: boolean;
|
||||
/** The viewer is not signed in; the sidebar degrades to brand plus a login link. */
|
||||
readonly signedOut: boolean;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id?: unknown;
|
||||
title?: unknown;
|
||||
pinned?: unknown;
|
||||
archived_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
};
|
||||
|
||||
type AccountBody = {
|
||||
credits?: unknown;
|
||||
user?: { id?: unknown; email?: unknown };
|
||||
profile?: { name?: unknown };
|
||||
avatar?: BeamAvatar | null;
|
||||
};
|
||||
|
||||
const EMPTY: SidebarDataState = { sessions: [], account: null, settled: false, signedOut: false };
|
||||
|
||||
export function toSidebarSession(row: SessionRow): SidebarSession | null {
|
||||
if (typeof row.id !== "string" || !row.id) return null;
|
||||
const updatedAt = typeof row.updated_at === "string" ? Date.parse(row.updated_at) : Number.NaN;
|
||||
return {
|
||||
id: row.id,
|
||||
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : "新对话",
|
||||
pinned: row.pinned === true,
|
||||
archived: typeof row.archived_at === "string" && row.archived_at !== "",
|
||||
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSidebarAccount(body: AccountBody | null): SidebarAccount {
|
||||
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
return {
|
||||
name: name || email || "账户",
|
||||
email,
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: typeof body?.credits === "number" ? body.credits : 0,
|
||||
avatar: body?.avatar ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which account the payload belongs to; the cache key. */
|
||||
export function sidebarAccountKey(body: AccountBody | null): string {
|
||||
const id = typeof body?.user?.id === "string" ? body.user.id : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
return id || email || "anonymous";
|
||||
}
|
||||
|
||||
export function useSidebarData(): SidebarDataState {
|
||||
const [state, setState] = useState<SidebarDataState>(() => {
|
||||
/* Read once, at mount: an entry from a previous visit renders in the first
|
||||
frame rather than after a round trip. Empty on the server, and empty on a
|
||||
real page load, which is what makes the first render match. */
|
||||
const cached = readSidebarCache();
|
||||
if (cached === null) return EMPTY;
|
||||
return { sessions: cached.sessions, account: cached.account, settled: true, signedOut: false };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readSidebarCache();
|
||||
if (sidebarCacheIsFresh(cached, Date.now())) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (typeof fetch !== "function") {
|
||||
setState((current) => ({ ...current, settled: true }));
|
||||
return;
|
||||
}
|
||||
const [sessionResult, accountResult] = await Promise.allSettled([
|
||||
fetch("/api/sessions?limit=40", { signal: controller.signal }),
|
||||
fetch("/api/account", { signal: controller.signal }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
let sessions: readonly SidebarSession[] | null = null;
|
||||
let account: SidebarAccount | null = null;
|
||||
let accountBody: AccountBody | null = null;
|
||||
let signedOut = false;
|
||||
|
||||
if (sessionResult.status === "fulfilled" && sessionResult.value.ok) {
|
||||
const body = await sessionResult.value.json().catch(() => null) as { sessions?: unknown } | null;
|
||||
const rows = Array.isArray(body?.sessions) ? body.sessions as SessionRow[] : [];
|
||||
sessions = rows.map(toSidebarSession).filter((item): item is SidebarSession => item !== null);
|
||||
} else if (sessionResult.status === "fulfilled" && sessionResult.value.status === 401) {
|
||||
signedOut = true;
|
||||
}
|
||||
|
||||
if (accountResult.status === "fulfilled" && accountResult.value.ok) {
|
||||
accountBody = await accountResult.value.json().catch(() => null) as AccountBody | null;
|
||||
account = toSidebarAccount(accountBody);
|
||||
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
|
||||
signedOut = true;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
if (signedOut) {
|
||||
/* A rejected session must not leave a list on screen, here or on the
|
||||
next page this tab opens. */
|
||||
invalidateSidebarCache();
|
||||
setState({ sessions: [], account: null, settled: true, signedOut: true });
|
||||
return;
|
||||
}
|
||||
if (sessions !== null || account !== null) {
|
||||
writeSidebarCache({
|
||||
accountId: sidebarAccountKey(accountBody),
|
||||
sessions: sessions ?? [],
|
||||
account,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
setState({ sessions: sessions ?? [], account, settled: true, signedOut: false });
|
||||
}
|
||||
|
||||
void load().catch(() => {
|
||||
if (!cancelled) setState((current) => ({ ...current, settled: true }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isGeneralDailyFortuneQuestion,
|
||||
isRectificationHandoffQuestion,
|
||||
} from "./consultation-entrypoint.ts";
|
||||
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
|
||||
|
||||
export type ReplyTheme = ConsultationDomain;
|
||||
|
||||
@@ -64,10 +65,14 @@ export type SessionTitleOptions = {
|
||||
readonly existingTitles?: readonly string[];
|
||||
};
|
||||
|
||||
const DATED_CATEGORY_PREFIX = /^(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日/;
|
||||
const DATED_CATEGORY_SUFFIX = /^\d{1,2}月\d{1,2}日\s*·\s*(?:生时校正|今日节奏)(?:\s+\d{2}:\d{2})?$/;
|
||||
|
||||
export function isGenericSessionTitle(title: string): boolean {
|
||||
const text = title.replace(/\s+/g, " ").trim();
|
||||
if (!text) return true;
|
||||
if (GENERIC_SESSION_TITLES.has(text)) return true;
|
||||
if (DATED_CATEGORY_PREFIX.test(text) || DATED_CATEGORY_SUFFIX.test(text)) return true;
|
||||
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
|
||||
}
|
||||
|
||||
@@ -76,15 +81,9 @@ function clipTitle(value: string, maxChars = 14): string {
|
||||
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value;
|
||||
}
|
||||
|
||||
function datedSessionTitle(at: Date, suffix: string): string {
|
||||
return `${at.getMonth() + 1}月${at.getDate()}日 · ${suffix}`;
|
||||
}
|
||||
|
||||
function uniquifySessionTitle(title: string, existingTitles: readonly string[], at: Date): string {
|
||||
if (!existingTitles.includes(title)) return title;
|
||||
const hours = String(at.getHours()).padStart(2, "0");
|
||||
const minutes = String(at.getMinutes()).padStart(2, "0");
|
||||
return `${title} ${hours}:${minutes}`;
|
||||
function datedSessionTitle(at: Date, category: string): string {
|
||||
const { month, day } = shanghaiDateParts(at);
|
||||
return `${category} · ${month}月${day}日`;
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(
|
||||
@@ -93,21 +92,20 @@ export function resolveSessionTitle(
|
||||
options: SessionTitleOptions = {},
|
||||
): string {
|
||||
const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at
|
||||
const existingTitles = options.existingTitles ?? [];
|
||||
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
|
||||
return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at);
|
||||
return clipTitle(modelTitle);
|
||||
}
|
||||
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
|
||||
return uniquifySessionTitle(datedSessionTitle(at, "今日节奏"), existingTitles, at);
|
||||
return datedSessionTitle(at, "今日节奏");
|
||||
}
|
||||
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
|
||||
return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at);
|
||||
return datedSessionTitle(at, "生时校正");
|
||||
}
|
||||
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
|
||||
if (!normalized) return "新对话";
|
||||
if (options.theme && options.theme !== "general") {
|
||||
const label = consultationDomainDefinition(options.theme).label;
|
||||
return uniquifySessionTitle(`${label} · ${clipTitle(normalized, 10)}`, existingTitles, at);
|
||||
return `${label} · ${clipTitle(normalized, 10)}`;
|
||||
}
|
||||
return uniquifySessionTitle(clipTitle(normalized), existingTitles, at);
|
||||
return clipTitle(normalized);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ type QueryResult = Readonly<{
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
type CmpOp = "eq" | "neq" | "lt" | "gt" | "lte" | "gte";
|
||||
|
||||
type Filter =
|
||||
| Readonly<{ kind: "eq"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "neq"; column: string; value: unknown }>
|
||||
@@ -18,7 +20,12 @@ type Filter =
|
||||
| Readonly<{ kind: "like"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
|
||||
| Readonly<{ kind: "is"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>;
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "or"; expression: string }>;
|
||||
|
||||
export type PostgrestOrNode =
|
||||
| Readonly<{ kind: "cmp"; column: string; op: CmpOp; value: unknown }>
|
||||
| Readonly<{ kind: "and"; nodes: readonly PostgrestOrNode[] }>;
|
||||
|
||||
type Mutation =
|
||||
| Readonly<{ kind: "insert"; rows: readonly Record<string, unknown>[] }>
|
||||
@@ -48,6 +55,93 @@ export function formatOrderClause(
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
const SQL_CMP: Record<CmpOp, string> = {
|
||||
eq: "=",
|
||||
neq: "<>",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
lte: "<=",
|
||||
gte: ">=",
|
||||
};
|
||||
|
||||
function splitTopLevel(expression: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let start = 0;
|
||||
for (let index = 0; index < expression.length; index += 1) {
|
||||
const char = expression[index];
|
||||
if (char === "\"" && expression[index - 1] !== "\\") inString = !inString;
|
||||
if (inString) continue;
|
||||
if (char === "(") depth += 1;
|
||||
else if (char === ")") depth -= 1;
|
||||
else if (char === "," && depth === 0) {
|
||||
parts.push(expression.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
parts.push(expression.slice(start));
|
||||
return parts.map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parsePostgrestValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "null") return null;
|
||||
if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parsePostgrestTerm(term: string): PostgrestOrNode {
|
||||
const andMatch = /^and\(([\s\S]*)\)$/.exec(term);
|
||||
if (andMatch?.[1] != null) {
|
||||
return { kind: "and", nodes: splitTopLevel(andMatch[1]).map(parsePostgrestTerm) };
|
||||
}
|
||||
const cmp = /^([a-z_][a-z0-9_]*)\.(eq|neq|lt|gt|lte|gte)\.([\s\S]*)$/.exec(term);
|
||||
if (!cmp) throw new Error(`unsupported or filter: ${term}`);
|
||||
return {
|
||||
kind: "cmp",
|
||||
column: cmp[1] ?? "",
|
||||
op: (cmp[2] ?? "eq") as CmpOp,
|
||||
value: parsePostgrestValue(cmp[3] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePostgrestOr(expression: string): PostgrestOrNode[] {
|
||||
return splitTopLevel(expression).map(parsePostgrestTerm);
|
||||
}
|
||||
|
||||
function compilePostgrestNode(
|
||||
node: PostgrestOrNode,
|
||||
parameters: unknown[],
|
||||
types: Map<string, string>,
|
||||
): string {
|
||||
if (node.kind === "and") {
|
||||
return `(${node.nodes.map((child) => compilePostgrestNode(child, parameters, types)).join(" and ")})`;
|
||||
}
|
||||
identifier(node.column);
|
||||
parameters.push(databaseValue(types.get(node.column), node.value));
|
||||
return `${identifier(node.column)} ${SQL_CMP[node.op]} $${parameters.length}`;
|
||||
}
|
||||
|
||||
export function compilePostgrestOr(
|
||||
expression: string,
|
||||
parameters: unknown[],
|
||||
types: Map<string, string>,
|
||||
): string {
|
||||
const nodes = parsePostgrestOr(expression);
|
||||
if (nodes.length === 0) return "true";
|
||||
if (nodes.length === 1) {
|
||||
const only = nodes[0];
|
||||
if (!only) return "true";
|
||||
return compilePostgrestNode(only, parameters, types);
|
||||
}
|
||||
return `(${nodes.map((node) => compilePostgrestNode(node, parameters, types)).join(" or ")})`;
|
||||
}
|
||||
|
||||
export function upsertConflictColumns(options?: { onConflict?: string }): string[] {
|
||||
return (options?.onConflict ?? "")
|
||||
.split(",")
|
||||
@@ -290,11 +384,21 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
|
||||
not(column: string, operator: string, value: unknown) {
|
||||
identifier(column);
|
||||
if (operator === "eq") {
|
||||
this.filters.push({ kind: "neq", column, value });
|
||||
return this;
|
||||
}
|
||||
if (operator !== "cs") throw new Error("unsupported not filter");
|
||||
this.filters.push({ kind: "notContains", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
or(expression: string) {
|
||||
if (!expression.trim()) throw new Error("empty or filter");
|
||||
this.filters.push({ kind: "or", expression });
|
||||
return this;
|
||||
}
|
||||
|
||||
order(column: string, options: { ascending?: boolean } = {}) {
|
||||
identifier(column);
|
||||
this.ordering.push({ column, ascending: options.ascending !== false });
|
||||
@@ -342,6 +446,9 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
): string {
|
||||
if (this.filters.length === 0) return "";
|
||||
const parts = this.filters.map((filter) => {
|
||||
if (filter.kind === "or") {
|
||||
return compilePostgrestOr(filter.expression, parameters, types);
|
||||
}
|
||||
const column = identifier(filter.column);
|
||||
if (filter.kind === "is") {
|
||||
if (filter.value === null) return `${column} is null`;
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createSession(
|
||||
theme: "general",
|
||||
modelId,
|
||||
messages: [],
|
||||
createdAt: timestamp(),
|
||||
updatedAt: timestamp(),
|
||||
sessionType,
|
||||
rectificationCaseId: null,
|
||||
@@ -247,18 +248,30 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
|
||||
export type SessionListPage = {
|
||||
readonly sessions: unknown;
|
||||
readonly nextCursor: string | null;
|
||||
readonly draft: unknown;
|
||||
};
|
||||
|
||||
export function readSessionListPage(value: unknown): SessionListPage {
|
||||
if (Array.isArray(value)) return { sessions: value, nextCursor: null };
|
||||
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null };
|
||||
const page = value as { sessions?: unknown; nextCursor?: unknown };
|
||||
if (Array.isArray(value)) return { sessions: value, nextCursor: null, draft: null };
|
||||
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null, draft: null };
|
||||
const page = value as { sessions?: unknown; nextCursor?: unknown; draft?: unknown };
|
||||
return {
|
||||
sessions: page.sessions,
|
||||
nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null,
|
||||
draft: page.draft ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readDraftConsultation(
|
||||
value: unknown,
|
||||
catalog: PublicLanguageModelCatalog | null,
|
||||
): ChatSession | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const parsed = readSessions([{ ...(value as object), messages: [] }], catalog).sessions[0];
|
||||
if (!parsed || parsed.sessionType !== "consultation") return null;
|
||||
return { ...parsed, messages: [], messagesHydrated: true };
|
||||
}
|
||||
|
||||
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
|
||||
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
|
||||
const fallbackSessionIds: string[] = [];
|
||||
@@ -271,6 +284,7 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
|
||||
chart_profile_name?: unknown;
|
||||
chart_profile_role?: unknown;
|
||||
session_type?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
archived_at?: unknown;
|
||||
};
|
||||
@@ -327,6 +341,15 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
|
||||
: typeof session.archivedAt === "string" && session.archivedAt
|
||||
? session.archivedAt
|
||||
: null,
|
||||
createdAt: typeof session.createdAt === "number"
|
||||
? session.createdAt
|
||||
: typeof session.created_at === "string"
|
||||
? Date.parse(session.created_at)
|
||||
: typeof session.updatedAt === "number"
|
||||
? session.updatedAt
|
||||
: typeof session.updated_at === "string"
|
||||
? Date.parse(session.updated_at)
|
||||
: timestamp(),
|
||||
updatedAt: typeof session.updatedAt === "number"
|
||||
? session.updatedAt
|
||||
: typeof session.updated_at === "string"
|
||||
|
||||
@@ -103,15 +103,6 @@ export function sessionChartLabel(session: ChatSession, library: readonly ChartL
|
||||
return current ? name : `资料已删除 · ${name}`;
|
||||
}
|
||||
|
||||
export function sessionSidebarTitle(session: ChatSession, _library?: readonly ChartLibraryRecord[]) {
|
||||
return session.title?.trim() || "新对话";
|
||||
}
|
||||
|
||||
export function sessionSidebarSubtitle(session: ChatSession, library: readonly ChartLibraryRecord[]) {
|
||||
if (session.chartProfileRole === "self" || !session.chartProfileId) return null;
|
||||
return sessionChartLabel(session, library);
|
||||
}
|
||||
|
||||
export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
|
||||
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
|
||||
const others = library.filter((record) => record.role !== "self");
|
||||
|
||||
@@ -79,6 +79,7 @@ export type ChatSession = {
|
||||
theme: Theme;
|
||||
modelId: string;
|
||||
messages: Message[];
|
||||
createdAt?: number;
|
||||
updatedAt: number;
|
||||
sessionType: ChatSessionType;
|
||||
rectificationCaseId: string | null;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
|
||||
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
import { readSessions } from "@/lib/home-cloud-sync";
|
||||
import type { Account, ChatSession } from "@/lib/home-types";
|
||||
import { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
|
||||
import { isListedSidebarSession } from "@/lib/session-list-filter";
|
||||
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
|
||||
|
||||
export type SessionListBoot = {
|
||||
readonly sessions: ChatSession[];
|
||||
readonly rawRows: unknown;
|
||||
readonly draftRow: unknown;
|
||||
readonly cursor: string | null;
|
||||
readonly account: Account | null;
|
||||
readonly signedOut: boolean;
|
||||
};
|
||||
|
||||
export type ShellRegistration = {
|
||||
readonly controls: AppSidebarControls;
|
||||
readonly escapeBlocked: boolean;
|
||||
readonly activeSessionId: string | null;
|
||||
readonly openingSessionId: string | null;
|
||||
readonly openErrorSessionId: string | null;
|
||||
readonly openErrorMessage: string;
|
||||
readonly insetClassName: string;
|
||||
readonly insetInert: boolean;
|
||||
readonly sidebarSessions?: readonly SidebarSession[];
|
||||
readonly sidebarAccount?: SidebarAccount;
|
||||
};
|
||||
|
||||
export type SessionListContextValue = {
|
||||
sessions: ChatSession[];
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
sessionsCursor: string | null;
|
||||
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
|
||||
account: Account | null;
|
||||
setAccount: Dispatch<SetStateAction<Account | null>>;
|
||||
signedOut: boolean;
|
||||
settled: boolean;
|
||||
ready: Promise<void>;
|
||||
boot: () => SessionListBoot | null;
|
||||
registerShellControls: (registration: ShellRegistration | null) => void;
|
||||
registration: ShellRegistration | null;
|
||||
};
|
||||
|
||||
const SessionListContext = createContext<SessionListContextValue | null>(null);
|
||||
|
||||
function createReadyGate(): { promise: Promise<void>; resolve: () => void } {
|
||||
let settle = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, resolve: () => settle() };
|
||||
}
|
||||
|
||||
function toSidebarAccount(account: Account | null): SidebarAccount | null {
|
||||
if (!account) return null;
|
||||
const profileName = account.profile && typeof account.profile === "object" && "name" in account.profile
|
||||
&& typeof account.profile.name === "string" ? account.profile.name.trim() : "";
|
||||
const name = profileName;
|
||||
const email = account.user.email || "";
|
||||
return {
|
||||
name: name || email || "账户",
|
||||
email,
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: account.credits,
|
||||
avatar: account.avatar,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] {
|
||||
return sessions.filter(isListedSidebarSession).map((session) => toSidebarSessionRow(session));
|
||||
}
|
||||
|
||||
async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
|
||||
const [sessionResponse, accountResponse] = await Promise.all([
|
||||
fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}`, { signal, cache: "no-store" }),
|
||||
fetch("/api/account", { signal, cache: "no-store" }),
|
||||
]);
|
||||
if (sessionResponse.status === 401 || accountResponse.status === 401) {
|
||||
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true };
|
||||
}
|
||||
const sessionPayload = await sessionResponse.json().catch(() => null) as {
|
||||
sessions?: unknown;
|
||||
nextCursor?: unknown;
|
||||
draft?: unknown;
|
||||
} | null;
|
||||
const accountPayload = await accountResponse.json().catch(() => null);
|
||||
if (!sessionResponse.ok || !accountResponse.ok) {
|
||||
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
|
||||
}
|
||||
const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : [];
|
||||
const parsed = readSessions(rawRows, null);
|
||||
const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null;
|
||||
return {
|
||||
sessions: parsed.sessions,
|
||||
rawRows,
|
||||
draftRow: sessionPayload?.draft ?? null,
|
||||
cursor,
|
||||
account: accountPayload as Account,
|
||||
signedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionListProvider({ children }: { children: ReactNode }) {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
const [settled, setSettled] = useState(false);
|
||||
const [registration, setRegistration] = useState<ShellRegistration | null>(null);
|
||||
const bootRef = useRef<SessionListBoot | null>(null);
|
||||
const [readyPack] = useState(createReadyGate);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadSessionList(controller.signal)
|
||||
.then((boot) => {
|
||||
if (controller.signal.aborted) return;
|
||||
bootRef.current = boot;
|
||||
setSessions(boot.sessions);
|
||||
setSessionsCursor(boot.cursor);
|
||||
setAccount(boot.account);
|
||||
setSignedOut(boot.signedOut);
|
||||
setSettled(true);
|
||||
readyPack.resolve();
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
|
||||
setSettled(true);
|
||||
readyPack.resolve();
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [readyPack]);
|
||||
|
||||
const registerShellControls = useCallback((next: ShellRegistration | null) => {
|
||||
setRegistration(next);
|
||||
}, []);
|
||||
|
||||
const boot = useCallback(() => bootRef.current, []);
|
||||
|
||||
const value = useMemo<SessionListContextValue>(() => ({
|
||||
sessions,
|
||||
setSessions,
|
||||
sessionsCursor,
|
||||
setSessionsCursor,
|
||||
account,
|
||||
setAccount,
|
||||
signedOut,
|
||||
settled,
|
||||
ready: readyPack.promise,
|
||||
boot,
|
||||
registerShellControls,
|
||||
registration,
|
||||
}), [
|
||||
account,
|
||||
boot,
|
||||
readyPack.promise,
|
||||
registerShellControls,
|
||||
registration,
|
||||
sessions,
|
||||
sessionsCursor,
|
||||
settled,
|
||||
signedOut,
|
||||
]);
|
||||
|
||||
return <SessionListContext.Provider value={value}>{children}</SessionListContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSessionList(): SessionListContextValue {
|
||||
const value = useContext(SessionListContext);
|
||||
if (!value) {
|
||||
throw new Error("useSessionList must be used inside SessionListProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sessionListSidebarModel(list: SessionListContextValue): {
|
||||
sessions: readonly SidebarSession[];
|
||||
account: SidebarAccount | null;
|
||||
} {
|
||||
const registered = list.registration;
|
||||
return {
|
||||
sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions),
|
||||
account: registered?.sidebarAccount ?? toSidebarAccount(list.account),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ChatSession } from "@/lib/home-types";
|
||||
|
||||
export function findReusableEmptyConsultation(
|
||||
sessions: readonly ChatSession[],
|
||||
): ChatSession | undefined {
|
||||
return sessions.find((session) => (
|
||||
session.sessionType === "consultation"
|
||||
&& !session.archivedAt
|
||||
&& session.messagesHydrated
|
||||
&& session.messages.length === 0
|
||||
));
|
||||
}
|
||||
|
||||
export function isListedSidebarSession(session: ChatSession): boolean {
|
||||
if (session.archivedAt) return false;
|
||||
if (session.sessionType === "birth_time_rectification") return true;
|
||||
if (!session.messagesHydrated) return true;
|
||||
return session.messages.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Product UI dates for session titles and subtitles are wall-clock in China. */
|
||||
|
||||
export const SESSION_CLOCK_TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
function part(
|
||||
parts: Intl.DateTimeFormatPart[],
|
||||
type: Intl.DateTimeFormatPartTypes,
|
||||
): string {
|
||||
return parts.find((item) => item.type === type)?.value ?? "";
|
||||
}
|
||||
|
||||
export function shanghaiDateParts(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): {
|
||||
month: number;
|
||||
day: number;
|
||||
} {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).formatToParts(at);
|
||||
return {
|
||||
month: Number(part(parts, "month")),
|
||||
day: Number(part(parts, "day")),
|
||||
};
|
||||
}
|
||||
|
||||
export function shanghaiDateTimeLabel(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(at);
|
||||
const month = Number(part(parts, "month"));
|
||||
const day = Number(part(parts, "day"));
|
||||
const hour = part(parts, "hour").padStart(2, "0");
|
||||
const minute = part(parts, "minute").padStart(2, "0");
|
||||
return `${month}月${day}日 ${hour}:${minute}`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
import { sessionChartLabel } from "@/lib/home-profile";
|
||||
import { shanghaiDateTimeLabel } from "@/lib/session-shanghai-clock";
|
||||
|
||||
export function sessionSidebarTitle(session: ChatSession) {
|
||||
return session.title?.trim() || "新对话";
|
||||
}
|
||||
|
||||
export function sessionSidebarSubtitle(
|
||||
session: ChatSession,
|
||||
library: readonly ChartLibraryRecord[] = [],
|
||||
): string {
|
||||
const created = session.createdAt || session.updatedAt;
|
||||
const clock = Number.isFinite(created) ? shanghaiDateTimeLabel(new Date(created)) : "";
|
||||
const parts: string[] = [];
|
||||
if (clock) parts.push(clock);
|
||||
if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) {
|
||||
parts.push(sessionChartLabel(session, library));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function toSidebarSessionRow(
|
||||
session: ChatSession,
|
||||
library: readonly ChartLibraryRecord[] = [],
|
||||
): SidebarSession {
|
||||
return {
|
||||
id: session.id,
|
||||
title: sessionSidebarTitle(session, library),
|
||||
subtitle: sessionSidebarSubtitle(session, library) || null,
|
||||
pinned: session.pinned,
|
||||
archived: Boolean(session.archivedAt),
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function clipHan(value: string, maxChars: number): string {
|
||||
}
|
||||
|
||||
const BIRTH_STAMP = /\d{4}年\d{1,2}月(?:\d{1,2}日)?|\d{1,2}:\d{2}/;
|
||||
const DATED_ENTRY_TITLE = /^\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正)$/;
|
||||
const DATED_ENTRY_TITLE = /^(?:(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+\d{2}:\d{2})?$/;
|
||||
|
||||
export function sanitizeSessionTitle(raw: string): string | null {
|
||||
if (/[\r\n]/.test(raw)) return null;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
|
||||
/**
|
||||
* The session list and account the read-only sidebar shows, kept in memory for
|
||||
* one tab.
|
||||
*
|
||||
* `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and
|
||||
* `GET /api/account` on every arrival, because each page mounted its own shell.
|
||||
* The shared `(secondary)` layout removes the per-page remount; this removes the
|
||||
* repeat when the reader leaves for `/` and comes back. Nothing durable is
|
||||
* written: a stale session list surviving a browser restart is worse than one
|
||||
* fetch, and it would outlive a sign-out.
|
||||
*
|
||||
* Keyed by account id so a second account in the same tab never reads the
|
||||
* first one's rows. The pointer is what makes a synchronous read possible: the
|
||||
* account id only arrives with the payload, so the reader cannot name its own
|
||||
* key before the first fetch has happened.
|
||||
*/
|
||||
|
||||
export const SIDEBAR_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export type SidebarCacheEntry = {
|
||||
readonly accountId: string;
|
||||
readonly sessions: readonly SidebarSession[];
|
||||
readonly account: SidebarAccount | null;
|
||||
readonly fetchedAt: number;
|
||||
};
|
||||
|
||||
const entries = new Map<string, SidebarCacheEntry>();
|
||||
let currentAccountId: string | null = null;
|
||||
|
||||
export function readSidebarCache(): SidebarCacheEntry | null {
|
||||
if (currentAccountId === null) return null;
|
||||
return entries.get(currentAccountId) ?? null;
|
||||
}
|
||||
|
||||
export function writeSidebarCache(entry: SidebarCacheEntry): void {
|
||||
entries.set(entry.accountId, entry);
|
||||
currentAccountId = entry.accountId;
|
||||
}
|
||||
|
||||
/** True when the entry may be shown without going back to the network. */
|
||||
export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean {
|
||||
if (entry === null) return false;
|
||||
const age = now - entry.fetchedAt;
|
||||
return age >= 0 && age < SIDEBAR_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from `/` after every session write — create, rename, delete, archive,
|
||||
* pin — and on any 401. Renaming a session and walking to `/chart` has to show
|
||||
* the new title, and a signed-out tab must not keep a list on screen.
|
||||
*/
|
||||
export function invalidateSidebarCache(): void {
|
||||
entries.clear();
|
||||
currentAccountId = null;
|
||||
}
|
||||
Reference in New Issue
Block a user