Merge remote-tracking branch 'origin/main' into codex/vp-jain-provenance
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user