Merge origin/main into agent-guided birth time rectification

This commit is contained in:
Jesse_Chen
2026-07-18 18:54:21 +08:00
170 changed files with 77128 additions and 355 deletions
+91 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { isAdminEmail } from "@/lib/supabase/admin";
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
} from "@/lib/supabase/config";
@@ -7,6 +7,35 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
type ProfilePatchPayload = {
name?: unknown;
birth_date?: unknown;
birth_time?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
latitude?: unknown;
longitude?: unknown;
timezone_offset?: unknown;
};
function nullableString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function nullableNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
|| error?.code === "42703"
|| message.includes("schema cache")
|| message.includes("column");
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -38,3 +67,64 @@ export async function GET() {
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
}
const admin = createAdminSupabaseClient();
const baseProfile = {
id: user.id,
name: nullableString(payload.name),
birth_date: nullableString(payload.birth_date),
birth_time: nullableString(payload.birth_time),
country_code: nullableString(payload.country_code),
province_code: nullableString(payload.province_code),
city_code: nullableString(payload.city_code),
district_code: nullableString(payload.district_code),
updated_at: new Date().toISOString(),
};
const withCoordinates = {
...baseProfile,
latitude: nullableNumber(payload.latitude),
longitude: nullableNumber(payload.longitude),
timezone_offset: nullableNumber(payload.timezone_offset),
};
const withoutCoordinates = baseProfile;
let { data, error } = await admin
.from("profiles")
.upsert(withCoordinates, { onConflict: "id" })
.select("id")
.single();
if (error && isMissingProfileColumn(error)) {
const fallback = await admin
.from("profiles")
.upsert(withoutCoordinates, { onConflict: "id" })
.select("id")
.single();
data = fallback.data;
error = fallback.error;
}
if (error || !data) {
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
}
return NextResponse.json({ ok: true });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
}
}
@@ -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,35 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
type RouteContext = {
params: Promise<{ id: string }>;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const { id } = await context.params;
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { error } = await supabase
.from("chart_profiles")
.delete()
.eq("id", id)
.eq("user_id", user.id)
.eq("role", "other");
if (error) throw error;
return NextResponse.json({ ok: true });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 });
}
}
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
type ChartProfilePayload = {
id?: string;
role?: "self" | "other";
profile?: unknown;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chart_profiles")
.select("id, role, profile, updated_at")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) throw error;
return NextResponse.json({ profiles: data ?? [] });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "星盘库暂时不可用") }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const body = await request.json().catch(() => null) as ChartProfilePayload | null;
if (!body?.profile || typeof body.profile !== "object") {
return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 });
}
const role = body.role === "self" ? "self" : "other";
const updatedAt = new Date().toISOString();
let data;
let error;
if (role === "self") {
const existing = await supabase
.from("chart_profiles")
.select("id")
.eq("user_id", user.id)
.eq("role", "self")
.maybeSingle();
if (existing.error) throw existing.error;
const query = existing.data?.id
? supabase
.from("chart_profiles")
.update({ profile: body.profile, updated_at: updatedAt })
.eq("id", existing.data.id)
.select("id, role, profile, updated_at")
.single()
: supabase
.from("chart_profiles")
.insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })
.select("id, role, profile, updated_at")
.single();
({ data, error } = await query);
} else {
const record = {
...(body.id ? { id: body.id } : {}),
user_id: user.id,
role,
profile: body.profile,
updated_at: updatedAt,
};
({ data, error } = await supabase
.from("chart_profiles")
.upsert(record, { onConflict: "id" })
.select("id, role, profile, updated_at")
.single());
}
if (error) throw error;
return NextResponse.json({ profile: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "星盘保存失败") }, { status: 500 });
}
}
@@ -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",
});
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
type Check = {
status: "ok" | "degraded" | "blocked";
message?: string;
latencyMs?: number;
};
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
function envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
return missing.length
? { status: "blocked", message: `missing:${missing.join(",")}` }
: { status: "ok" };
}
function anyEnvCheck(names: string[]): Check {
return names.some((name) => process.env[name])
? { status: "ok" }
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
}
async function jyotishApiCheck(): Promise<Check> {
const started = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`${jyotishApiBase}/api/health`, {
cache: "no-store",
signal: controller.signal,
});
return {
status: response.ok ? "ok" : "degraded",
message: response.ok ? undefined : `http:${response.status}`,
latencyMs: Date.now() - started,
};
} catch (error) {
return {
status: "blocked",
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
latencyMs: Date.now() - started,
};
} finally {
clearTimeout(timeout);
}
}
function aggregate(checks: Record<string, Check>) {
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
return "ok";
}
export async function GET() {
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
return NextResponse.json(
{
status,
timestamp: new Date().toISOString(),
checks,
},
{ status: status === "ok" ? 200 : 503 },
);
}
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
type SynastryReportPayload = {
id?: string;
partnerName?: string;
report?: unknown;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("synastry_reports")
.select("id, partner_name, report, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(10);
if (error) throw error;
return NextResponse.json({ reports: data ?? [] });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null) as SynastryReportPayload | null;
if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) {
return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 });
}
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const partnerName = body.partnerName?.trim() || "对方";
const record = {
...(body.id ? { id: body.id } : {}),
user_id: user.id,
partner_name: partnerName,
report: body.report,
created_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from("synastry_reports")
.insert(record)
.select("id, partner_name, report, created_at")
.single();
if (error) throw error;
return NextResponse.json({ report: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 });
}
}
+159
View File
@@ -0,0 +1,159 @@
import { NextResponse } from "next/server";
import { chinaLocations } from "@/data/china-locations";
type Profile = {
name?: string;
date?: string;
time?: string;
countryCode?: "CN";
provinceCode?: string;
cityCode?: string;
districtCode?: string;
};
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const china = chinaLocations.country;
function birthPayload(profile: Profile) {
const [year, month, day] = String(profile.date || "").split("-").map(Number);
const [hour, minute] = String(profile.time || "").split(":").map(Number);
const province = china.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, hour, minute].every(Number.isFinite) || !location) {
throw new Error("birth_profile_incomplete");
}
return {
year, month, day, hour, minute,
second: 0,
lat: location.center[1],
lon: location.center[0],
tz: china.timezone,
};
}
function moonLongitude(chart: Record<string, unknown>) {
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
const value = moon.lon ?? moon.longitude ?? moon.degree;
const numeric = Number(value);
if (!Number.isFinite(numeric)) throw new Error("moon_longitude_missing");
return numeric;
}
function moonSummary(chart: Record<string, unknown>) {
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
return {
sign: moon.sign,
nakshatra: moon.nakshatra,
pada: moon.nakshatra_pada,
lord: moon.nakshatra_lord,
longitude: moonLongitude(chart),
};
}
function d9Summary(varga: Record<string, unknown>) {
const result = varga.result && typeof varga.result === "object" ? varga.result as Record<string, unknown> : {};
const d9 = result.D9_Navamsa && typeof result.D9_Navamsa === "object" ? result.D9_Navamsa as Record<string, unknown> : {};
const ascendant = d9.ascendant && typeof d9.ascendant === "object" ? d9.ascendant as Record<string, unknown> : {};
const planets = d9.planets && typeof d9.planets === "object" ? d9.planets as Record<string, unknown> : {};
return {
ascendant,
moon: planets.Moon,
venus: planets.Venus,
mars: planets.Mars,
source: varga.source,
};
}
function planetSign(point: unknown) {
return point && typeof point === "object" && "sign" in point
? String((point as Record<string, unknown>).sign || "unknown")
: "unknown";
}
function relationshipReport(synastry: Record<string, unknown>, selfD9: Record<string, unknown>, partnerD9: Record<string, unknown>) {
const total = Number(synastry.total_score ?? 0);
const max = Number(synastry.max_score ?? 36);
const ratio = max > 0 ? total / max : 0;
const band = ratio >= 0.72 ? "supportive" : ratio >= 0.5 ? "mixed" : "challenging";
const self = d9Summary(selfD9);
const partner = d9Summary(partnerD9);
return {
status: "evidence_summary",
scoreBand: band,
headline: band === "supportive"
? "基础匹配度偏支持,但仍需结合现实互动与长期运势。"
: band === "mixed"
? "基础匹配度中等,适合重点观察沟通节奏、价值观与关系承诺。"
: "基础匹配度偏谨慎,需要先处理冲突模式与现实条件。",
strengths: [
`Ashtakoot ${total}/${max}`,
`本人 D9 Moon${planetSign(self.moon)}`,
`对方 D9 Moon${planetSign(partner.moon)}`,
],
risks: [
"这不是完整婚恋结论;尚未纳入双方 Dasha、UL/DK 与长期时机。",
`D9 Venus/Mars 需要继续解释:本人 ${planetSign(self.venus)}/${planetSign(self.mars)},对方 ${planetSign(partner.venus)}/${planetSign(partner.mars)}`,
],
nextEvidence: ["双方 Dasha", "UL/DK", "D9 7宫/7主", "现实关系时间线"],
};
}
async function postPython(path: string, body: unknown) {
const response = await fetch(`${apiBase}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45_000),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data || typeof data !== "object") {
throw new Error(`jyotish_api_${response.status}`);
}
return data as Record<string, unknown>;
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null;
if (!body?.selfProfile || !body.partnerProfile) {
return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 });
}
const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile));
const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile));
const selfD9 = await postPython("/api/varga_full", {
...birthPayload(body.selfProfile),
planets: selfChart.planets,
ascendant: selfChart.ascendant,
divisions: ["D9"],
});
const partnerD9 = await postPython("/api/varga_full", {
...birthPayload(body.partnerProfile),
planets: partnerChart.planets,
ascendant: partnerChart.ascendant,
divisions: ["D9"],
});
const synastry = await postPython("/api/synastry", {
male_moon: moonLongitude(selfChart),
female_moon: moonLongitude(partnerChart),
});
return NextResponse.json({
status: "ok",
method: "ashtakoot_plus_moon_nakshatra_d9",
evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"],
selfChart: { moon: moonSummary(selfChart), d9: d9Summary(selfD9) },
partnerChart: { moon: moonSummary(partnerChart), d9: d9Summary(partnerD9) },
synastry,
relationshipReport: relationshipReport(synastry, selfD9, partnerD9),
});
} catch (error) {
return NextResponse.json({
status: "blocked",
error: error instanceof Error ? error.message : "synastry_unavailable",
message: "合盘计算暂时不可用;可先保留合盘问题草稿。",
}, { status: 503 });
}
}
+62 -13
View File
@@ -267,13 +267,26 @@ 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: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms 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(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
.session-nav-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); }
.sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
.session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; }
.session-list { min-height: 0; 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(--sidebar-muted-foreground); }
.session-list [data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
.session-list [data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
.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) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); }
.session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; }
.session-title > svg { width: var(--space-3); height: var(--space-3); flex: 0 0 auto; color: var(--sidebar-ring); }
.session-main small { color: inherit; line-height: 1.3; opacity: .72; font-size: var(--type-overline); }
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
.session-menu-trigger > svg { width: 18px; height: 18px; }
.session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; }
.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-actions { position: absolute; z-index: 4; top: calc(100% - var(--space-1)); right: 0; min-width: calc(var(--space-24) + var(--space-12)); display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
.session-actions button { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font-size: var(--type-body-sm); }
.session-actions button > svg { width: 16px; height: 16px; }
.session-actions button:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
.session-actions .session-action-danger { color: var(--color-danger); }
.sidebar-footer { position: relative; margin-top: var(--space-3); padding-top: 10px; border-top: 1px solid var(--sidebar-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(--sidebar-foreground); 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; }
@@ -404,15 +417,27 @@ button:disabled { cursor: default; opacity: .45; }
.birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; }
.phrase-nowrap { white-space: nowrap; }
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; }
.starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.starter-list button:first-child { min-height: 196px; grid-row: 1 / span 2; align-content: end; border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); }
.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: 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); }
.starter-content { min-width: 0; display: grid; gap: var(--space-2); }
.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: 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, .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, .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; }
@@ -506,6 +531,30 @@ button:disabled { cursor: default; opacity: .45; }
.account-redeem-form { padding-top: var(--space-5); }
.logout-copy { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.6; text-wrap: pretty; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
.sheet-section { border-bottom: 1px solid var(--color-border); padding: var(--space-6) 0; border-color: var(--color-border); }
.section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.section-toggle small, .section-heading small { display: block; margin-top: 4px; color: var(--color-ink-secondary); font-weight: 400; font-size: var(--type-caption); }
.default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
.chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); }
.chart-library-group { display: grid; gap: var(--space-3); }
.chart-library-group > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.chart-library-item strong, .chart-library-item small { display: block; }
.chart-library-item strong { color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
.chart-library-item small, .chart-library-item > span, .empty-library-copy { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.chart-library-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.chart-library-form { padding-top: var(--space-4); border-top: 1px solid var(--color-border); }
.synastry-report-card { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.synastry-report-card span, .synastry-report-card small, .synastry-report-card li { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-report-card strong, .synastry-report-card p { color: var(--color-ink); }
.synastry-report-card strong { display: block; margin-top: 4px; font-size: var(--type-body-md); font-weight: 500; }
.synastry-report-card p, .synastry-report-card ul { margin: 0; }
.synastry-history-list { display: grid; gap: var(--space-2); }
.synastry-history-list > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; }
.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
@@ -567,8 +616,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
@media (min-width: 768px) and (max-width: 900px) {
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
.starter-list button, .starter-list button:first-child { min-height: 92px; grid-row: auto; padding: var(--space-4); }
.starter-list button:first-child { min-height: 112px; }
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
.product-entrypoints { grid-template-columns: 1fr; }
.auth-shell { grid-template-columns: 1.2fr .8fr; }
.auth-story, .auth-panel { padding: var(--space-8); }
.auth-story h2 { font-size: var(--type-display-sm); }
@@ -588,8 +637,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.welcome { padding: var(--space-6) 0 var(--space-10); }
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-display-sm); }
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
.starter-list button, .starter-list button:first-child { min-height: 96px; grid-row: auto; padding: var(--space-4); }
.starter-list button:first-child { min-height: 136px; }
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
.product-entrypoints { grid-template-columns: 1fr; }
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
.message-content { max-width: 88%; }
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
+685 -16
View File
@@ -56,6 +56,37 @@ type Profile = BirthTimeDraft & {
districtCode: string;
rectificationCaseId: string;
};
type ChartLibraryRecord = {
id: string;
role: "self" | "other";
profile: Profile;
updatedAt: number;
};
type ChartLibraryApiRecord = {
id: string;
role: "self" | "other";
profile: Profile;
updated_at?: string;
};
type SynastryReportCard = {
id: string;
partnerName: string;
score?: number;
maxScore?: number;
assessment?: string;
headline?: string;
scoreBand?: string;
strengths?: string[];
risks?: string[];
nextEvidence?: string[];
createdAt: number;
};
type SynastryReportApiRecord = {
id: string;
partner_name?: string;
report?: SynastryReportCard;
created_at?: string;
};
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
type RequestError = { sessionId: string; message: string };
type StreamingReply = { sessionId: string; text: string };
@@ -66,6 +97,21 @@ type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[]
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type AccountDialog = "profile" | "redeem" | "logout";
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;
@@ -111,6 +157,13 @@ const previewModelCatalog = parsePublicModelCatalog({
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const dailyStarlanguageCards: DailyStarlanguageCard[] = [
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
];
const greetingVariants: Record<GreetingPeriod, Array<(name: string) => string>> = {
morning: [
(name) => `早上好,${name}。今天最想先看什么?`,
@@ -210,6 +263,173 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone };
}
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
function synastryHistoryStorageKey(accountId: string) {
return `jyotisha_synastry_history:${accountId}`;
}
function profileReadyForLibrary(profile: Profile) {
return !missingProfileStep(profile);
}
function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
return { id: "self", role: "self", profile, updatedAt: timestamp() };
}
function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
const others = library.filter((record) => record.role !== "self");
return [buildSelfChartRecord(profile), ...others];
}
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
try {
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : [];
} catch {
return [];
}
}
function readSynastryHistory(accountId: string): SynastryReportCard[] {
try {
const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : [];
} catch {
return [];
}
}
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
}
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
if (!record.report || typeof record.report !== "object") return null;
return {
...record.report,
id: record.id,
partnerName: record.partner_name || record.report.partnerName || "对方",
createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(),
};
}
async function fetchCloudSynastryHistory() {
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
if (!response.ok) throw new Error("cloud_synastry_history_unavailable");
const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null;
return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[];
}
async function saveCloudSynastryReport(report: SynastryReportCard) {
const response = await fetch("/api/synastry-reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partnerName: report.partnerName, report }),
});
if (!response.ok) throw new Error("cloud_synastry_report_save_failed");
const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null;
return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report;
}
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
return {
id: record.role === "self" ? "self" : record.id,
role: record.role,
profile: record.profile,
updatedAt: Date.parse(record.updated_at || "") || timestamp(),
};
}
async function fetchCloudChartLibrary() {
const response = await fetch("/api/chart-profiles", { cache: "no-store" });
if (!response.ok) throw new Error("cloud_chart_library_unavailable");
const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null;
return (payload?.profiles || []).map(normalizeChartLibraryApiRecord);
}
async function saveCloudChartProfile(record: ChartLibraryRecord) {
const response = await fetch("/api/chart-profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: record.role === "self" ? undefined : record.id,
role: record.role,
profile: record.profile,
}),
});
if (!response.ok) throw new Error("cloud_chart_profile_save_failed");
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null;
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
}
async function deleteCloudChartProfile(recordId: string) {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" });
if (!response.ok) throw new Error("cloud_chart_profile_delete_failed");
}
function profilePlaceLabel(profile: Profile) {
return selectedBirthPlace(profile)?.label || "地点未完整";
}
function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) {
return [
`请用印度占星合盘分析我和${partnerProfile.name || "对方"}的关系。`,
`我的资料:${selfProfile.name || "本人"}${selfProfile.date} ${selfProfile.time}${profilePlaceLabel(selfProfile)}`,
`对方资料:${partnerProfile.name || "对方"}${partnerProfile.date} ${partnerProfile.time}${profilePlaceLabel(partnerProfile)}`,
"请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。",
].join("\n");
}
function buildDailyStarlanguageQuestion(profile: Profile) {
const today = new Date().toISOString().slice(0, 10);
return [
`请生成今日星语:${today},对象是${profile.name || "我"}`,
`出生资料:${profile.date} ${profile.time}${profilePlaceLabel(profile)}`,
"请输出今日趋势、适合推进的事、需要避开的事、一个行动建议。",
"边界:这是探索性日提示,不是确定预测;若涉及精确事件日期,请标为候选触发,不要包装成必然结论。",
].join("\n");
}
function buildDailyStarlanguageCard(profile: Profile) {
const today = new Date().toISOString().slice(0, 10);
const seed = `${today}-${profile.date}-${profile.time}-${profile.provinceCode}-${profile.cityCode}`;
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % dailyStarlanguageCards.length;
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 || "我"}做生时校正辅助。`,
`当前记录:${profile.date} ${profile.time || "时间不确定"}${profilePlaceLabel(profile)}`,
"请先列出需要我补充的关键人生事件,再给候选出生时间段、每段会影响的 Lagna/分盘/大运差异。",
"边界:候选出生时间段必须标为待验证,不能直接改写默认星盘;没有事件证据前不要声称校正完成。",
].join("\n");
}
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!isBirthTimeDraftReady(profile)) return "birth";
@@ -398,12 +618,12 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p
);
}
function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
return (
<>
<label>
<span></span>
<input id="profile-name" required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange({ ...value, ...patch })} />
<BirthLocationFields value={value} onChange={onChange} />
@@ -499,6 +719,13 @@ export default function Home() {
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
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 [profileNotice, setProfileNotice] = useState("");
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
@@ -508,6 +735,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("");
@@ -547,6 +778,7 @@ export default function Home() {
const modelSyncFailures = useRef(new Set<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
@@ -559,16 +791,98 @@ export default function Home() {
});
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]);
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]);
useEffect(() => {
if (!sessionMenuId) return;
function closeSessionMenu(event: Event) {
if (event instanceof globalThis.KeyboardEvent && event.key !== "Escape") return;
if (event instanceof MouseEvent && (event.target as Element | null)?.closest(".session-row")) return;
setSessionMenuId(null);
}
window.addEventListener("mousedown", closeSessionMenu);
window.addEventListener("keydown", closeSessionMenu);
return () => {
window.removeEventListener("mousedown", closeSessionMenu);
window.removeEventListener("keydown", closeSessionMenu);
};
}, [sessionMenuId]);
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 (!accountId) {
setChartLibrary([]);
setSynastryHistory([]);
chartLibraryLoadedAccount.current = "";
return;
}
if (chartLibraryLoadedAccount.current === accountId) return;
chartLibraryLoadedAccount.current = accountId;
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
setSynastryHistory(readSynastryHistory(accountId));
void fetchCloudChartLibrary()
.then((cloudLibrary) => {
setChartLibrary((current) => {
const otherById = new Map([
...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
]);
const next = upsertSelfChart([...otherById.values()], profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
})
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
});
}, [accountId, profile]);
useEffect(() => {
if (!accountId) return;
setChartLibrary((current) => {
const next = upsertSelfChart(current, profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
}, [accountId, profile]);
const profileComplete = isProfileComplete(profile);
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
@@ -837,6 +1151,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" });
@@ -912,6 +1258,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;
const nextSession = createSession(modelCatalog.defaultModelId);
@@ -1029,9 +1439,11 @@ export default function Home() {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
const birthPlace = selectedBirthPlace(nextProfile);
const { data, error } = await createBrowserSupabaseClient()
.from("profiles")
.update({
const response = await fetch("/api/account", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
...birthTimePersistenceValues(nextProfile),
@@ -1042,13 +1454,70 @@ export default function Home() {
latitude: birthPlace?.lat ?? null,
longitude: birthPlace?.lon ?? null,
timezone_offset: birthPlace?.tz ?? null,
updated_at: new Date().toISOString(),
})
.eq("id", account.user.id)
.select("id")
.maybeSingle();
if (error) throw error;
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null);
}
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
if (missingProfileStep(nextProfile)) {
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
return;
}
if (!accountId) return;
let record: ChartLibraryRecord = {
id: globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
updatedAt: timestamp(),
};
try {
record = await saveCloudChartProfile(record);
} catch {
// Keep local chart library usable when cloud sync is unavailable.
}
setChartLibrary((current) => {
const next = [...upsertSelfChart(current, profile), record];
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setOtherProfileDraft(emptyProfile);
setAccountError("");
setProfileNotice("已添加到星盘库。");
}
function deleteOtherChart(recordId: string) {
if (!accountId) return;
void deleteCloudChartProfile(recordId).catch(() => {
// Local deletion should not be blocked by temporary cloud sync failures.
});
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
}
async function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving) return;
setProfileSaving(true);
setAccountError("");
try {
await persistProfile(record.profile);
setProfile(record.profile);
setProfileDraft(record.profile);
setProfileNotice("已设为当前默认星盘。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败"));
} finally {
setProfileSaving(false);
}
}
async function assessSavedBirthTime(nextProfile: Profile) {
@@ -1255,6 +1724,75 @@ export default function Home() {
window.requestAnimationFrame(() => composerInput.current?.focus());
}
function draftDailyStarlanguageQuestion() {
chooseSuggestedQuestion(buildDailyStarlanguageQuestion(profile), "timing");
}
function draftBirthTimeRectificationQuestion() {
chooseSuggestedQuestion(buildBirthTimeRectificationQuestion(profile), "timing");
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) {
if (record.role !== "other") return;
const baseQuestion = buildSynastryQuestion(profile, record.profile);
try {
const response = await fetch("/api/synastry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }),
});
const payload = await response.json().catch(() => null) as { status?: string; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null;
if (response.ok && payload?.status === "ok") {
const score = payload.synastry?.total_score;
const max = payload.synastry?.max_score;
const assessment = payload.synastry?.assessment;
const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9";
const reportCard: SynastryReportCard = {
id: `${record.id}-${Date.now()}`,
partnerName: record.profile.name || "对方",
score,
maxScore: max,
assessment,
headline: payload.relationshipReport?.headline,
scoreBand: payload.relationshipReport?.scoreBand,
strengths: payload.relationshipReport?.strengths,
risks: payload.relationshipReport?.risks,
nextEvidence: payload.relationshipReport?.nextEvidence,
createdAt: Date.now(),
};
let savedReportCard = reportCard;
if (accountId) {
try {
savedReportCard = await saveCloudSynastryReport(reportCard);
} catch {
// Local history remains the fallback when cloud persistence is unavailable.
}
}
setSynastryReportCard(savedReportCard);
if (accountId) {
setSynastryHistory((current) => {
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
}
chooseSuggestedQuestion([
baseQuestion,
"",
`已计算基础合盘证据:${layers}Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`,
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
].join("\n"), "marriage");
} else {
chooseSuggestedQuestion(baseQuestion, "marriage");
setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。");
}
} catch {
chooseSuggestedQuestion(baseQuestion, "marriage");
setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。");
}
closeAccountDialog();
}
async function requestCancellation(requestId: string) {
const existing = cancellationRequests.current.get(requestId);
if (existing) return existing;
@@ -1690,10 +2228,12 @@ export default function Home() {
|| "你",
};
const sidebarSessions = sessions.map((session) => ({
const sidebarSessions = visibleSessions.map((session) => ({
id: session.id,
title: session.title,
messageCount: session.messages.length,
pinned: pinnedSessionIds.includes(session.id),
archived: archivedSessionIds.includes(session.id),
}));
return (
@@ -1707,6 +2247,31 @@ export default function Home() {
accountTriggerRef={accountTrigger}
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
creatingSession={creatingSession}
sessionControls={{
archivedCount: archivedSessionIds.length,
showingArchived: showArchivedSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onToggleArchivedView: () => {
setShowArchivedSessions((current) => !current);
setSessionMenuId(null);
},
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onToggleArchived: toggleArchivedSession,
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void deleteSession(session);
},
}}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onSelectSession={selectSession}
@@ -1805,6 +2370,32 @@ export default function Home() {
<div className="starter-loading" role="status"></div>
) : (
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
<div className="product-entrypoints" aria-label="常用占星入口">
<article className="daily-starlanguage-card" aria-label="今日星语">
<div className="daily-starlanguage-heading">
<span></span>
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftDailyStarlanguageQuestion}> <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
</div>
<dl>
<div><dt></dt><dd>{dailyStarlanguage?.trend}</dd></div>
<div><dt></dt><dd>{dailyStarlanguage?.action}</dd></div>
<div><dt></dt><dd>{dailyStarlanguage?.caution}</dd></div>
</dl>
<small></small>
</article>
<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 (
@@ -1924,10 +2515,88 @@ export default function Home() {
{activeAccountDialog === "profile" && (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<section className="sheet-section birth-section">
<div className="section-heading"><b></b><small></small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
<div>
<span></span>
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small></small>
</div>
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}></button>
</div>
{chartLibraryOpen && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span></span>
</article>
))}
</div>
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy"></p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record)}></button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
</article>
))}
</div>
{synastryReportCard && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span></span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary></summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{synastryHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b></b>
{synastryHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}></button>
</form>
</div>
)}
</section>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
<button className="button-primary save-profile" type="submit" disabled={profileSaving}>{profileSaving ? "保存中" : "保存出生资料"}</button>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</>
)}