Merge remote-tracking branch 'origin/main' into codex/vp-jain-provenance

This commit is contained in:
732642856
2026-07-18 06:30:23 +08:00
6 changed files with 477 additions and 45 deletions
@@ -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<string, unknown>) {
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<string, unknown>;
} 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",
});
}
@@ -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<string, unknown>) {
const modules = chart.modules && typeof chart.modules === "object" ? chart.modules as Record<string, unknown> : {};
const chartModule = modules.chart && typeof modules.chart === "object" ? modules.chart as Record<string, unknown> : 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<string, unknown>) {
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<string, unknown>;
} 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<string, unknown> : {};
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",
});
}
+22 -13
View File
@@ -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; }
+217 -32
View File
@@ -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<Profile>(emptyProfile);
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [birthRectificationPreview, setBirthRectificationPreview] = useState<BirthRectificationPreview | null>(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<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
const [activeSessionId, setActiveSessionId] = useState("");
const [draft, setDraft] = useState("");
@@ -672,16 +714,32 @@ export default function Home() {
const uiPreviewMode = useRef<string | null>(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() {
<div className="brand-row"><span className="brand-mark" aria-hidden="true" /><strong>Jyotisha</strong><button className="sidebar-close" ref={sidebarCloseButton} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)}><X aria-hidden="true" /></button></div>
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
<nav className="session-nav" aria-label="聊天记录">
<span className="sidebar-label">聊天记录</span>
<div className="session-nav-header">
<span className="sidebar-label">{showArchivedSessions ? "归档记录" : "聊天记录"}</span>
<button type="button" onClick={() => { setShowArchivedSessions((current) => !current); setSessionMenuId(null); }}>
{showArchivedSessions ? "返回" : `归档 ${archivedSessionIds.length}`}
</button>
</div>
<div className="session-list">
{sessions.map((session) => (
<button
className={session.id === activeSession?.id ? "is-active" : ""}
{visibleSessions.map((session) => (
<div
className={`session-row ${session.id === activeSession?.id ? "is-active" : ""}`}
key={session.id}
type="button"
onClick={() => {
setActiveSessionId(session.id);
setDraft("");
setComposerNotice("");
setMobileSidebarOpen(false);
onContextMenu={(event) => {
event.preventDefault();
setSessionMenuId(sessionMenuId === session.id ? null : session.id);
}}
disabled={Boolean(pendingSessionId) || cancellationPending}
aria-current={session.id === activeSession?.id ? "page" : undefined}
>
<span>{session.title}</span>
{session.messages.length > 0 && <small>{session.messages.length} 条消息</small>}
</button>
<button
type="button"
onClick={() => {
setActiveSessionId(session.id);
setDraft("");
setComposerNotice("");
setSessionMenuId(null);
setMobileSidebarOpen(false);
}}
disabled={Boolean(pendingSessionId) || cancellationPending}
aria-current={session.id === activeSession?.id ? "page" : undefined}
>
<span>{pinnedSessionIds.includes(session.id) ? "★ " : ""}{session.title}</span>
{session.messages.length > 0 && <small>{session.messages.length} 条消息</small>}
</button>
<button className="session-menu-trigger" type="button" aria-label={`${session.title} 更多操作`} aria-expanded={sessionMenuId === session.id} onClick={() => setSessionMenuId(sessionMenuId === session.id ? null : session.id)}>⋯</button>
{sessionMenuId === session.id && (
<div className="session-actions" role="menu" aria-label={`${session.title} 操作`}>
<button type="button" role="menuitem" onClick={() => { togglePinnedSession(session.id); setSessionMenuId(null); }}>{pinnedSessionIds.includes(session.id) ? "取消置顶" : "置顶"}</button>
<button type="button" role="menuitem" onClick={() => { setSessionMenuId(null); void renameSession(session); }}>重命名</button>
<button type="button" role="menuitem" onClick={() => { setSessionMenuId(null); void shareSession(session); }}>转发</button>
<button type="button" role="menuitem" onClick={() => { toggleArchivedSession(session.id); setSessionMenuId(null); }}>{archivedSessionIds.includes(session.id) ? "恢复" : "归档"}</button>
<button type="button" role="menuitem" onClick={() => { setSessionMenuId(null); void deleteSession(session); }}>删除</button>
</div>
)}
</div>
))}
</div>
</nav>
@@ -1958,16 +2135,6 @@ export default function Home() {
<div className="starter-loading" role="status">正在准备三个入门问题…</div>
) : (
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
const theme = themes.find((candidate) => candidate.id === item.theme);
return (
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={() => chooseSuggestedQuestion(item.text, item.theme)}>
<span className="starter-content"><b>{theme?.label || "开始"}</b><span>{item.text}</span></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
);
})}
{onboardingError && <p className="starter-note">Agent 的个性化入门问题暂时不可用,已显示安全的默认问题。</p>}
<div className="product-entrypoints" aria-label="常用占星入口">
<article className="daily-starlanguage-card" aria-label="今日星语">
<div className="daily-starlanguage-heading">
@@ -1981,11 +2148,29 @@ export default function Home() {
</dl>
<small>探索性日提示,不是确定预测。</small>
</article>
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>
<span><b>生时校正</b><small>收集事件证据并给候选出生时间段,不能直接改写默认星盘。</small></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
<article className="birth-rectification-card" aria-label="生时校正">
<div className="daily-starlanguage-heading">
<span>生时校正</span>
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>开始校正 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
</div>
<dl>
<div><dt>候选出生时间段</dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
<div><dt>候选点</dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}</dd></div>
<div><dt>问题数</dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
</dl>
<small>不能直接改写默认星盘;需事件证据验证。</small>
</article>
</div>
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
const theme = themes.find((candidate) => candidate.id === item.theme);
return (
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={() => chooseSuggestedQuestion(item.text, item.theme)}>
<span className="starter-content"><b>{theme?.label || "开始"}</b><span>{item.text}</span></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
);
})}
{onboardingError && <p className="starter-note">Agent 的个性化入门问题暂时不可用,已显示安全的默认问题。</p>}
</div>
))}
<div ref={conversationEnd} />
@@ -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
@@ -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