From f5e6d5164651cb3b3da57edc2af1523992dfe9c2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 30 Aug 2026 15:55:39 +0800 Subject: [PATCH] feat(frontend): bind sessions to chart profiles --- frontend/src/app/api/sessions/route.ts | 2 +- frontend/src/app/globals.css | 1 + frontend/src/app/page.tsx | 82 ++++++++++++++++++- .../src/lib/chat-session-write-contract.ts | 6 ++ ...000_chat_session_chart_profile_binding.sql | 14 ++++ frontend/tests/chat-session-write.test.ts | 20 +++++ 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 frontend/supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index ce6fe523..978c0540 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -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 }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 59068c08..767528b4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 97af0982..af88166b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 & { 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 { 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() {
{activeSession?.title || "新对话"} + 分析对象:{activeSession ? sessionChartLabel(activeSession, chartLibrary) : "未关联资料"}
diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index 5341ee57..44b9d013 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -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; }>; diff --git a/frontend/supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql b/frontend/supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql new file mode 100644 index 00000000..31406b56 --- /dev/null +++ b/frontend/supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql @@ -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; diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 2e9031bb..eba23b88 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -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 \}>/); assert.match(contract, /\): z\.ZodType \{/); + 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\)/); });