diff --git a/frontend/src/app/api/birth-rectification/route.ts b/frontend/src/app/api/birth-rectification/route.ts new file mode 100644 index 00000000..9ac14b7b --- /dev/null +++ b/frontend/src/app/api/birth-rectification/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { chinaLocations } from "@/data/china-locations"; + +type Profile = { + date?: string; + time?: string; + provinceCode?: string; + cityCode?: string; + districtCode?: string; +}; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + +function payloadFromProfile(profile: Profile) { + if (!profile.date || !profile.time) return null; + const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode); + const city = province?.cities.find((item) => item.code === profile.cityCode); + const district = city?.districts.find((item) => item.code === profile.districtCode); + const location = district ?? city; + if (!location) return null; + return { + birth_time: `${profile.date} ${profile.time}`, + uncertainty_minutes: 30, + step_minutes: 2, + lat: location.center[1], + lon: location.center[0], + tz: chinaLocations.country.timezone, + }; +} + +async function fetchQuestionnaire(payload: Record) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`${jyotishApiBase}/api/active_rectification_questions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + cache: "no-store", + signal: controller.signal, + }); + if (!response.ok) throw new Error(`jyotish_api_${response.status}`); + return await response.json() as Record; + } finally { + clearTimeout(timeout); + } +} + +export async function POST(request: Request) { + const body = await request.json().catch(() => null) as { profile?: Profile } | null; + const payload = payloadFromProfile(body?.profile ?? {}); + if (!payload) { + return NextResponse.json({ + status: "blocked", + boundary: "not_auto_rectified", + reason: "profile_incomplete", + }); + } + const questionnaire = await fetchQuestionnaire(payload).catch(() => null); + const candidateScan = questionnaire?.candidate_scan && typeof questionnaire.candidate_scan === "object" + ? questionnaire.candidate_scan + : null; + const questions = Array.isArray(questionnaire?.questions) ? questionnaire.questions : []; + return NextResponse.json({ + status: questionnaire ? "ok" : "blocked", + candidate_scan: candidateScan, + question_count: questions.length, + boundary: "not_auto_rectified", + source: questionnaire ? "active_rectification_questions" : "fallback_unavailable", + }); +} diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts new file mode 100644 index 00000000..c3303ed9 --- /dev/null +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { chinaLocations } from "@/data/china-locations"; + +type Profile = { + name?: string; + date?: string; + time?: string; + provinceCode?: string; + cityCode?: string; + districtCode?: string; +}; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + +const cards = [ + { trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" }, + { trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" }, + { trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" }, + { trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" }, +]; + +function pickCard(profile: Profile, today: string) { + const seed = `${today}-${profile.date ?? ""}-${profile.time ?? ""}-${profile.provinceCode ?? ""}-${profile.cityCode ?? ""}`; + const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % cards.length; + return cards[index]; +} + +function profilePayload(profile: Profile, today: string) { + if (!profile.date || !profile.time) return null; + const [year, month, day] = profile.date.split("-").map(Number); + const [hour, minute] = profile.time.split(":").map(Number); + const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode); + const city = province?.cities.find((item) => item.code === profile.cityCode); + const district = city?.districts.find((item) => item.code === profile.districtCode); + const location = district ?? city; + if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute) || !location) return null; + return { + year, + month, + day, + hour, + minute, + lat: location.center[1], + lon: location.center[0], + tz: chinaLocations.country.timezone, + transit_date: today, + today, + ayanamsa: "lahiri", + node_mode: "mean", + }; +} + +function chartPoints(chart: Record) { + const modules = chart.modules && typeof chart.modules === "object" ? chart.modules as Record : {}; + const chartModule = modules.chart && typeof modules.chart === "object" ? modules.chart as Record : chart; + const planets = chartModule.planets && typeof chartModule.planets === "object" ? chartModule.planets : undefined; + const ascendant = chartModule.ascendant && typeof chartModule.ascendant === "object" ? chartModule.ascendant : undefined; + return planets && ascendant ? { planets, ascendant } : null; +} + +async function fetchJson(path: string, body: Record) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`${jyotishApiBase}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + signal: controller.signal, + }); + if (!response.ok) throw new Error(`jyotish_api_${response.status}`); + return await response.json() as Record; + } finally { + clearTimeout(timeout); + } +} + +async function transitBackedCard(profile: Profile, today: string) { + const payload = profilePayload(profile, today); + if (!payload) return null; + const chart = await fetchJson("/api/chart", payload); + const points = chartPoints(chart); + if (!points) return null; + const tomorrow = new Date(`${today}T00:00:00.000Z`); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + const transit = await fetchJson("/api/transit", { + natal_planets: points.planets, + ascendant: points.ascendant, + start: today, + end: tomorrow.toISOString().slice(0, 10), + planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu"], + }); + const summary = transit.summary && typeof transit.summary === "object" ? transit.summary as Record : {}; + const total = Number(summary.total_triggers ?? 0); + return { + trend: total > 0 ? `今日有 ${total} 个可观察过境触发点,适合把它当作时间窗口观察。` : "今日未发现强精确过境触发,适合按本命节奏稳步推进。", + action: "把今日计划压缩到一件主事,并记录实际发生的触发点。", + caution: "过境触发不能单独定事件,需与 Dasha、分盘和本命承诺交叉确认。", + }; +} + +export async function POST(request: Request) { + const body = await request.json().catch(() => null) as { profile?: Profile; today?: string } | null; + const profile = body?.profile ?? {}; + const today = body?.today || new Date().toISOString().slice(0, 10); + const transitCard = await transitBackedCard(profile, today).catch(() => null); + return NextResponse.json({ + status: "ok", + card: transitCard ?? pickCard(profile, today), + source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite", + claim_status: "exploratory_unvalidated", + boundary: "not_deterministic_prediction", + }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b1152780..76114aa8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -262,13 +262,22 @@ button:disabled { cursor: default; opacity: .45; } .brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--color-border); } .auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--color-border); } .new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 0; background: var(--color-surface-dark); color: var(--color-on-dark); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 140ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; } -.sidebar-label { display: block; padding: 0 var(--space-3) var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } +.session-nav-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); } +.sidebar-label { display: block; color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } +.session-nav-header button { min-height: 26px; padding: 0 8px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas); color: var(--color-ink-secondary); cursor: pointer; font-size: 11px; } .session-list { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-1); } -.session-list button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: var(--color-ink-secondary); } -.session-list button.is-active { color: var(--color-ink); background: var(--color-selected); } -.session-list button.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); } -.session-list button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.session-list button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; } +.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 30px; align-items: center; border-radius: var(--radius-md); color: var(--color-ink-secondary); } +.session-row > button:first-child { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: inherit; } +.session-row.is-active { color: var(--color-ink); background: var(--color-selected); } +.session-row.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); } +.session-row > button:first-child > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } +.session-row > button:first-child small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; } +.session-menu-trigger { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 999px; background: transparent; color: var(--color-ink-tertiary); cursor: pointer; opacity: 0; transition: opacity 120ms ease-out, background-color 120ms ease-out; } +.session-row:hover .session-menu-trigger, .session-row.is-active .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } +.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--color-canvas); color: var(--color-ink); } +.session-actions { position: absolute; z-index: 4; top: calc(100% - 4px); right: var(--space-1); min-width: 112px; display: grid; gap: 2px; padding: 6px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } +.session-actions button { min-height: 30px; width: 100%; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--color-ink-secondary); text-align: left; font-size: 12px; } +.session-actions button:hover { background: var(--color-canvas-muted); color: var(--color-ink); } .sidebar-footer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--color-border); } .profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } .profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; } @@ -303,7 +312,7 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); } .starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; } -.starter-list button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 156px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); } +.starter-list button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 136px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .starter-list button:first-child { border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); } .starter-list button:first-child .starter-content span { color: var(--color-ink); white-space: normal; } .starter-list button:first-child .starter-arrow { color: var(--color-action); } @@ -311,18 +320,18 @@ button:disabled { cursor: default; opacity: .45; } .starter-content b { color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } .starter-content span { overflow: hidden; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.4; text-overflow: clip; text-wrap: pretty; white-space: normal; } .starter-arrow { width: 17px; height: 17px; color: var(--color-ink-secondary); } -.product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: var(--space-3); } .product-entrypoints button { min-height: 132px; align-items: end; border: 1px solid var(--color-border); background: var(--color-canvas); } .product-entrypoints span { display: grid; gap: var(--space-2); } .product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); } -.daily-starlanguage-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); } +.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); } .daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; } .daily-starlanguage-heading button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; color: var(--color-ink); font-size: var(--type-caption); } -.daily-starlanguage-card dl { display: grid; gap: var(--space-2); margin: 0; } -.daily-starlanguage-card div { display: grid; gap: 4px; } -.daily-starlanguage-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); } -.daily-starlanguage-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; } +.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; } +.daily-starlanguage-card div, .birth-rectification-card div { display: grid; gap: 4px; } +.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); } +.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; } .starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; } .starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 749990d0..dfde091e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -74,6 +74,20 @@ type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] type OnboardingStep = "name" | "birth" | "place"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; type DailyStarlanguageCard = { trend: string; action: string; caution: string }; +type DailyStarlanguageApiResponse = { + status?: "ok"; + card?: DailyStarlanguageCard; + source?: "calculation_lite"; + claim_status?: "exploratory_unvalidated"; + boundary?: "not_deterministic_prediction"; +}; +type BirthRectificationPreview = { + status?: "ok" | "blocked"; + candidate_scan?: { start?: string; end?: string; candidate_count?: number }; + question_count?: number; + boundary?: "not_auto_rectified"; + source?: "active_rectification_questions" | "fallback_unavailable"; +}; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; type PendingConsultation = { readonly requestId: string; @@ -341,6 +355,28 @@ function buildDailyStarlanguageCard(profile: Profile) { return dailyStarlanguageCards[index]; } +async function fetchDailyStarlanguage(profile: Profile) { + const response = await fetch("/api/daily-starlanguage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile }), + }); + if (!response.ok) throw new Error("daily_starlanguage_unavailable"); + const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null; + if (payload?.status !== "ok" || !payload.card) throw new Error("daily_starlanguage_invalid"); + return payload.card; +} + +async function fetchBirthRectificationPreview(profile: Profile) { + const response = await fetch("/api/birth-rectification", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile }), + }); + if (!response.ok) throw new Error("birth_rectification_preview_unavailable"); + return await response.json().catch(() => null) as BirthRectificationPreview | null; +} + function buildBirthTimeRectificationQuestion(profile: Profile) { return [ `请为${profile.name || "我"}做生时校正辅助。`, @@ -617,6 +653,8 @@ export default function Home() { const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); + const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); + const [birthRectificationPreview, setBirthRectificationPreview] = useState(null); const [profileOpen, setProfileOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [profileNotice, setProfileNotice] = useState(""); @@ -629,6 +667,10 @@ export default function Home() { const [redeeming, setRedeeming] = useState(false); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); + const [pinnedSessionIds, setPinnedSessionIds] = useState([]); + const [archivedSessionIds, setArchivedSessionIds] = useState([]); + const [showArchivedSessions, setShowArchivedSessions] = useState(false); + const [sessionMenuId, setSessionMenuId] = useState(null); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const [draft, setDraft] = useState(""); @@ -672,16 +714,32 @@ export default function Home() { const uiPreviewMode = useRef(null); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; + const visibleSessions = sessions + .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) + .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; + const accountId = account?.user.id; useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); - const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? []; - const accountId = account?.user.id; + useEffect(() => { + if (!hydrated || !accountId) return; + const prefix = `jyotisha-session-controls:${accountId}:`; + setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]")); + setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]")); + }, [accountId, hydrated]); + + useEffect(() => { + if (!hydrated || !accountId) return; + const prefix = `jyotisha-session-controls:${accountId}:`; + localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds)); + localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); + }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); + const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? []; useEffect(() => { if (!accountId) { setChartLibrary([]); @@ -732,7 +790,7 @@ export default function Home() { }, [accountId, profile]); const profileComplete = isProfileComplete(profile); - const dailyStarlanguage = profileComplete ? buildDailyStarlanguageCard(profile) : null; + const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null); const onboardingPending = profileComplete && !onboarding && !onboardingError; const currentOnboardingMessage = onboardingJustCompleted ? startGreeting || completedOnboardingMessage(profileDraft.name.trim()) @@ -967,6 +1025,38 @@ export default function Home() { }; }, [accountId, hydrated, onboarding, onboardingError, profile.name, profileComplete, startGreeting]); + useEffect(() => { + if (!hydrated || !profileComplete) return; + let cancelled = false; + setDailyStarlanguageCard(null); + void fetchDailyStarlanguage(profile) + .then((card) => { + if (!cancelled) setDailyStarlanguageCard(card); + }) + .catch(() => { + if (!cancelled) setDailyStarlanguageCard(buildDailyStarlanguageCard(profile)); + }); + return () => { + cancelled = true; + }; + }, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]); + + useEffect(() => { + if (!hydrated || !profileComplete) return; + let cancelled = false; + setBirthRectificationPreview(null); + void fetchBirthRectificationPreview(profile) + .then((preview) => { + if (!cancelled) setBirthRectificationPreview(preview); + }) + .catch(() => { + if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" }); + }); + return () => { + cancelled = true; + }; + }, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]); + useEffect(() => { const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" }); @@ -1051,6 +1141,70 @@ export default function Home() { if (insertError) throw new Error(`云端同步失败:${insertError.message}`); } + async function renameSession(session: ChatSession) { + const title = window.prompt("重命名聊天记录", session.title)?.trim(); + if (!title || title === session.title) return; + const nextSession = { ...session, title, updatedAt: timestamp() }; + updateSession(session.id, () => nextSession); + try { + await persistSession(nextSession); + } catch (caught) { + setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); + } + } + + async function deleteSession(session: ChatSession) { + if (!account || !window.confirm(`删除“${session.title}”?此操作不可恢复。`)) return; + const previousSessions = sessions; + const nextSessions = sessions.filter((item) => item.id !== session.id); + setSessions(nextSessions); + setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); + setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); + if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); + try { + const supabase = createBrowserSupabaseClient(); + const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id); + if (error) throw error; + } catch (caught) { + setSessions(previousSessions); + setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); + } + } + + function togglePinnedSession(sessionId: string) { + setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + } + + function toggleArchivedSession(sessionId: string) { + setArchivedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + if (activeSessionId === sessionId) setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); + } + + async function shareSession(session: ChatSession) { + const sharePayload = { + share_payload_version: 1, + exported_at: new Date().toISOString(), + title: session.title, + theme: session.theme, + message_count: session.messages.length, + messages: session.messages.map((message) => ({ role: message.role, text: message.text })), + }; + const transcript = [ + `Jyotisha 对话:${session.title}`, + "", + ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), + "", + "---- JSON 分享包 ----", + JSON.stringify(sharePayload, null, 2), + ].join("\n"); + try { + await navigator.clipboard.writeText(transcript); + setComposerNotice("已复制当前聊天,可粘贴转发。"); + } catch { + setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); + } + } + async function startNewChat() { if (!account || !modelCatalog || creatingSession) return; setMobileSidebarOpen(false); @@ -1862,25 +2016,48 @@ export default function Home() {
@@ -1958,16 +2135,6 @@ export default function Home() {
正在准备三个入门问题…
) : (
- {(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => { - const theme = themes.find((candidate) => candidate.id === item.theme); - return ( - - ); - })} - {onboardingError &&

Agent 的个性化入门问题暂时不可用,已显示安全的默认问题。

}
@@ -1981,11 +2148,29 @@ export default function Home() { 探索性日提示,不是确定预测。
- +
+
+ 生时校正 + +
+
+
候选出生时间段
{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}
+
候选点
{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}
+
问题数
{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}
+
+ 不能直接改写默认星盘;需事件证据验证。 +
+ {(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => { + const theme = themes.find((candidate) => candidate.id === item.theme); + return ( + + ); + })} + {onboardingError &&

Agent 的个性化入门问题暂时不可用,已显示安全的默认问题。

}
))}
diff --git a/tests/test_daily_and_rectification_entrypoints.py b/tests/test_daily_and_rectification_entrypoints.py index e2357789..40abc5f4 100644 --- a/tests/test_daily_and_rectification_entrypoints.py +++ b/tests/test_daily_and_rectification_entrypoints.py @@ -2,11 +2,14 @@ from pathlib import Path PAGE = Path("frontend/src/app/page.tsx") +DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts") +RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts") def test_daily_starlanguage_entrypoint_is_productized() -> None: source = PAGE.read_text(encoding="utf-8") assert "今日星语" in source + assert "fetchDailyStarlanguage" in source assert "buildDailyStarlanguageCard" in source assert "daily-starlanguage-card" in source assert "今日趋势" in source @@ -17,9 +20,30 @@ def test_daily_starlanguage_entrypoint_is_productized() -> None: assert "不是确定预测" in source +def test_daily_starlanguage_api_declares_honest_source_boundary() -> None: + source = DAILY_ROUTE.read_text(encoding="utf-8") + assert "status: \"ok\"" in source + assert "/api/chart" in source + assert "/api/transit" in source + assert "jyotish_api_transit_lite" in source + assert "calculation_lite" in source + assert "exploratory_unvalidated" in source + assert "not_deterministic_prediction" in source + + def test_birth_time_rectification_entrypoint_is_productized() -> None: source = PAGE.read_text(encoding="utf-8") assert "生时校正" in source + assert "fetchBirthRectificationPreview" in source + assert "birth-rectification-card" in source assert "draftBirthTimeRectificationQuestion" in source assert "候选出生时间段" in source assert "不能直接改写默认星盘" in source + + +def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None: + source = RECTIFICATION_ROUTE.read_text(encoding="utf-8") + assert "/api/active_rectification_questions" in source + assert "candidate_scan" in source + assert "question_count" in source + assert "not_auto_rectified" in source diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py new file mode 100644 index 00000000..e592fbec --- /dev/null +++ b/tests/test_session_management_entrypoints.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +PAGE = Path("frontend/src/app/page.tsx") + + +def test_chat_history_management_actions_are_exposed() -> None: + source = PAGE.read_text(encoding="utf-8") + for expected in ( + "renameSession", + "deleteSession", + "togglePinnedSession", + "toggleArchivedSession", + "showArchivedSessions", + "shareSession", + "share_payload_version", + "messages.map", + "onContextMenu", + "session-menu-trigger", + 'role="menu"', + "置顶", + "重命名", + "归档", + "恢复", + "删除", + "转发", + ): + assert expected in source