feat(frontend): bind sessions to chart profiles
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user