feat(frontend): bind sessions to chart profiles

This commit is contained in:
Jesse_Chen
2026-08-30 15:55:39 +08:00
parent 5c1d095f69
commit f5e6d51646
6 changed files with 120 additions and 5 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ export async function GET() {
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
+1
View File
@@ -814,6 +814,7 @@ button:disabled { cursor: default; opacity: .45; }
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
.chat-header > div { min-width: 0; }
.chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; }
.chat-header-subtitle { max-width: min(560px, 62vw); overflow: hidden; color: var(--color-muted-foreground); font-size: var(--type-caption); line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
.chat-header-actions > * { flex: 0 0 auto; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
+78 -4
View File
@@ -221,6 +221,11 @@ type SynastryReportApiRecord = {
created_at?: string;
};
type ChatSessionType = "consultation" | "birth_time_rectification";
type ChatProfileBinding = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
};
type ChatSession = {
id: string;
title: string;
@@ -230,6 +235,9 @@ type ChatSession = {
updatedAt: number;
sessionType: ChatSessionType;
rectificationCaseId: string | null;
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
};
type RequestError = { sessionId: string; message: string };
@@ -415,6 +423,7 @@ function timestamp() {
function createSession(
modelId: string,
sessionType: ChatSessionType = "consultation",
chartBinding: ChatProfileBinding = { chartProfileId: null, chartProfileName: null, chartProfileRole: null },
): ChatSession {
return {
id: globalThis.crypto.randomUUID(),
@@ -425,6 +434,7 @@ function createSession(
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
...chartBinding,
};
}
@@ -513,6 +523,36 @@ function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() };
}
function chartSnapshotForSession(
chartId: string,
library: readonly ChartLibraryRecord[],
fallbackProfile: Profile,
): ChatProfileBinding {
const record = library.find((item) => item.id === chartId);
if (record) {
return {
chartProfileId: record.id,
chartProfileName: record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料"),
chartProfileRole: record.role,
};
}
if (chartId === "self") {
return { chartProfileId: "self", chartProfileName: fallbackProfile.name.trim() || "我", chartProfileRole: "self" };
}
return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null };
}
function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) {
if (!session.chartProfileId) return "未关联资料";
const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId);
const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料");
return current ? name : `资料已删除 · ${name}`;
}
function sessionSidebarTitle(session: ChatSession, library: readonly ChartLibraryRecord[]) {
return `${sessionChartLabel(session, library)} · ${session.title || "新对话"}`;
}
function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
const others = library.filter((record) => record.role !== "self");
@@ -843,6 +883,9 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
const session = item as Partial<ChatSession> & {
model_id?: unknown;
rectification_case_id?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
session_type?: unknown;
updated_at?: unknown;
};
@@ -887,6 +930,11 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
rectificationCaseId: typeof session.rectification_case_id === "string"
? session.rectification_case_id
: null,
chartProfileId: typeof session.chart_profile_id === "string" ? session.chart_profile_id : null,
chartProfileName: typeof session.chart_profile_name === "string" ? session.chart_profile_name : null,
chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other"
? session.chart_profile_role
: null,
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
@@ -1654,6 +1702,9 @@ export default function Home() {
updatedAt: timestamp(),
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
};
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
@@ -1699,11 +1750,16 @@ export default function Home() {
fetchSessions(controller.signal),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const nextProfile = readProfile(nextAccount.profile);
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
const initialSession = createSession(
nextModelCatalog?.defaultModelId ?? "",
"consultation",
chartSnapshotForSession("self", [], nextProfile),
);
if (nextModelCatalog) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
@@ -1712,6 +1768,9 @@ export default function Home() {
messages: initialSession.messages,
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
chart_profile_id: initialSession.chartProfileId,
chart_profile_name: initialSession.chartProfileName,
chart_profile_role: initialSession.chartProfileRole,
updated_at: new Date(initialSession.updatedAt).toISOString(),
}, "create");
}
@@ -1760,7 +1819,6 @@ export default function Home() {
if (controller.signal.aborted) return;
clearStaleClientReload(sessionStorage);
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
@@ -2155,6 +2213,9 @@ export default function Home() {
})),
session_type: session.sessionType,
rectification_case_id: session.rectificationCaseId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
updated_at: new Date(session.updatedAt).toISOString(),
};
await writeChatSession(session.id, values, mode);
@@ -2235,7 +2296,11 @@ export default function Home() {
async function startNewChat(): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = createSession(modelCatalog.defaultModelId);
const nextSession = createSession(
modelCatalog.defaultModelId,
"consultation",
chartSnapshotForSession(activeChartId, chartLibrary, profile),
);
const previousSessionId = activeSession?.id ?? "";
setCreatingSession(true);
setSessions((current) => [nextSession, ...current]);
@@ -2273,6 +2338,13 @@ export default function Home() {
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
if (nextSession?.chartProfileId) {
const boundChart = chartLibrary.find((record) => record.id === nextSession.chartProfileId);
if (boundChart && boundChart.id !== activeChartId && accountId) {
setActiveChartId(boundChart.id);
localStorage.setItem(activeChartStorageKey(accountId), boundChart.id);
}
}
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (nextSession.id !== rectificationSessionId) {
@@ -2830,6 +2902,7 @@ export default function Home() {
updatedAt: timestamp(),
sessionType: "birth_time_rectification",
rectificationCaseId: opened.caseId,
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
void persistSession(merged).catch(() => {});
@@ -3859,7 +3932,7 @@ export default function Home() {
const sidebarSessions = visibleSessions.map((session) => ({
id: session.id,
title: session.title,
title: sessionSidebarTitle(session, chartLibrary),
pinned: pinnedSessionIds.includes(session.id),
archived: archivedSessionIds.includes(session.id),
}));
@@ -4150,6 +4223,7 @@ export default function Home() {
<SidebarTrigger placement="inset" />
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span className="chat-header-subtitle">{activeSession ? sessionChartLabel(activeSession, chartLibrary) : "未关联资料"}</span>
</div>
<div className="chat-header-actions">
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
@@ -34,6 +34,9 @@ const chatSessionWriteObjectSchema = z.object({
messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
chart_profile_id: z.string().trim().max(100).nullable().optional(),
chart_profile_name: z.string().trim().max(80).nullable().optional(),
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
updated_at: z.string().datetime(),
}).strict();
@@ -101,6 +104,9 @@ export type ChatSessionWrite = Readonly<{
}>[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
updated_at: string;
}>;
@@ -0,0 +1,14 @@
begin;
alter table public.chat_sessions
add column if not exists chart_profile_id text,
add column if not exists chart_profile_name text,
add column if not exists chart_profile_role text
check (chart_profile_role in ('self', 'other'));
grant insert (chart_profile_id, chart_profile_name, chart_profile_role)
on table public.chat_sessions to authenticated;
grant update (chart_profile_id, chart_profile_name, chart_profile_role)
on table public.chat_sessions to authenticated;
commit;
+20
View File
@@ -26,6 +26,19 @@ test("create schema keeps the client-generated session id after transcript limit
assert.equal(parsed.messages[0]?.text, "你好");
});
test("chat session schema accepts chart profile snapshots and keeps legacy writes valid", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
chart_profile_id: "other-profile-id",
chart_profile_name: "张三",
chart_profile_role: "other",
});
assert.equal(parsed.chart_profile_id, "other-profile-id");
assert.equal(parsed.chart_profile_name, "张三");
assert.equal(parsed.chart_profile_role, "other");
assert.equal(chatSessionWriteSchema.parse(values).chart_profile_id, undefined);
});
test("chat session schema preserves the safe agent execution receipt", () => {
const receipt = {
runId: "run-1",
@@ -136,6 +149,7 @@ test("session API owns create and update while answer UI keeps sync failures out
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
const migration = readFileSync(new URL("../supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql", import.meta.url), "utf8");
assert.match(page, /thinkingText: message\.thinkingText/);
assert.match(page, /thinkingSections: message\.thinkingSections/);
@@ -151,6 +165,12 @@ test("session API owns create and update while answer UI keeps sync failures out
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
assert.match(page, /chartSnapshotForSession/);
assert.match(page, /chart_profile_name: session\.chartProfileName/);
assert.match(page, /分析对象:/);
assert.match(migration, /add column if not exists chart_profile_id text/);
assert.match(migration, /grant insert \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
assert.match(migration, /grant update \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
});