feat(home): write the daily card with the Agent and vary the starter heading
Independent Staging Quality Gate / validate (push) Failing after 11m34s
Independent Staging Quality Gate / publish (push) Has been skipped

The daily starlanguage card claimed to be personal but was a four-card
rotation picked by hashing the date and birth place, with the same pool
duplicated as a client fallback. It now collects chart, Vimshottari and
Narayana dasha, D9/D10 and today's transits, hands that evidence to a
dedicated Agent, and keeps the result per account per day in process. A
failed generation says so instead of printing generic advice.

The starter heading is drawn from a pool on each visit, and the
rectification card drops its fine print.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 22:25:52 +08:00
parent fd415e1d11
commit f6715ee4bf
10 changed files with 671 additions and 90 deletions
+147 -55
View File
@@ -1,22 +1,79 @@
import { NextResponse } from "next/server";
import {
dailyStarlanguageCacheKey,
dailyStarlanguageEvidence,
dailyStarlanguagePrompt,
parseDailyStarlanguageText,
type DailyStarlanguageCard,
} from "@/lib/daily-starlanguage";
import {
dailyProfilePayload,
type GlobalBirthProfile as Profile,
type GlobalBirthProfile,
} from "@/lib/global-birth-payloads";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getDailyStarlanguageAgent } from "@/mastra";
export const runtime = "nodejs";
export const maxDuration = 60;
type Profile = GlobalBirthProfile & { birthTimeStatus?: string };
type BirthPayload = NonNullable<Awaited<ReturnType<typeof dailyProfilePayload>>>;
type CacheEntry = { readonly day: string; readonly card: DailyStarlanguageCard };
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const engineTimeoutMs = 8_000;
const agentTimeoutMs = 30_000;
const cacheLimit = 500;
const cards = [
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
];
const state = globalThis as typeof globalThis & {
jyotishaDailyStarlanguageCache?: Map<string, CacheEntry>;
jyotishaDailyStarlanguagePending?: Map<string, Promise<DailyStarlanguageCard | null>>;
};
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 cache() {
state.jyotishaDailyStarlanguageCache ??= new Map();
return state.jyotishaDailyStarlanguageCache;
}
function pending() {
state.jyotishaDailyStarlanguagePending ??= new Map();
return state.jyotishaDailyStarlanguagePending;
}
function readCache(key: string, today: string): DailyStarlanguageCard | null {
const entry = cache().get(key);
if (!entry) return null;
if (entry.day !== today) {
cache().delete(key);
return null;
}
return entry.card;
}
function writeCache(key: string, today: string, card: DailyStarlanguageCard) {
const store = cache();
for (const [storedKey, entry] of store) {
if (entry.day !== today) store.delete(storedKey);
}
while (store.size >= cacheLimit) {
const oldest = store.keys().next();
if (oldest.done) break;
store.delete(oldest.value);
}
store.set(key, { day: today, card });
}
async function fetchEngine(path: string, body: Record<string, unknown>) {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(engineTimeoutMs),
});
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
return await response.json() as Record<string, unknown>;
}
function chartPoints(chart: Record<string, unknown>) {
@@ -27,57 +84,92 @@ function chartPoints(chart: Record<string, unknown>) {
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 = await dailyProfilePayload(profile, today);
if (!payload) return null;
const chart = await fetchJson("/api/chart", payload);
async function collectLayers(payload: BirthPayload, today: string) {
const chart = await fetchEngine("/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、分盘和本命承诺交叉确认。",
};
const withPoints = { ...payload, planets: points.planets, ascendant: points.ascendant };
const [vimshottari, narayana, varga, transit] = await Promise.all([
fetchEngine("/api/dasha", { ...withPoints, dasha: "vimshottari", today }).catch(() => null),
fetchEngine("/api/dasha", { ...withPoints, dasha: "narayana", today }).catch(() => null),
fetchEngine("/api/varga_full", { ...withPoints, divisions: ["D9", "D10"] }).catch(() => null),
fetchEngine("/api/transit", {
natal_planets: points.planets,
ascendant: points.ascendant,
start: today,
end: tomorrow.toISOString().slice(0, 10),
planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu", "Mars", "Sun"],
}).catch(() => null),
]);
return { chart, vimshottari, narayana, varga, transit };
}
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);
async function generateCard(payload: BirthPayload, profile: Profile, today: string) {
const layers = await collectLayers(payload, today);
if (!layers) return null;
const modelId = process.env.DAILY_STARLANGUAGE_MODEL_ID?.trim() || "deepseek-v4-flash";
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === modelId)
?? catalog.models.find((entry) => entry.id === catalog.defaultModelId)
?? null;
if (!model) return null;
const evidence = dailyStarlanguageEvidence(layers, today, profile.birthTimeStatus === "confirmed");
const result = await getDailyStarlanguageAgent(model).generate(
[{ role: "user", content: dailyStarlanguagePrompt(evidence) }],
{ abortSignal: AbortSignal.timeout(agentTimeoutMs) },
);
return parseDailyStarlanguageText(result.text);
}
function unavailable(reason: string) {
return NextResponse.json({
status: "ok",
card: transitCard ?? pickCard(profile, today),
source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite",
status: "unavailable",
reason,
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
}
export async function POST(request: Request) {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ status: "unauthenticated" }, { status: 401 });
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 payload = await dailyProfilePayload(profile, today).catch(() => null);
if (!payload) return unavailable("birth_profile_incomplete");
const key = dailyStarlanguageCacheKey(user.id, JSON.stringify(payload), today);
const cached = readCache(key, today);
if (cached) {
return NextResponse.json({
status: "ok",
card: cached,
source: "agent_cache",
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
}
const inFlight = pending().get(key) ?? generateCard(payload, profile, today)
.catch((error: unknown) => {
console.warn("daily_starlanguage_generation_failed", error);
return null;
})
.finally(() => pending().delete(key));
pending().set(key, inFlight);
const card = await inFlight;
if (!card) return unavailable("agent_generation_failed");
writeCache(key, today, card);
return NextResponse.json({
status: "ok",
card,
source: "agent",
claim_status: "exploratory_unvalidated",
boundary: "not_deterministic_prediction",
});
+1
View File
@@ -1211,6 +1211,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
margin-left: auto;
gap: var(--space-1);
color: var(--color-action);
font-size: var(--type-caption);
+41 -35
View File
@@ -115,6 +115,7 @@ import {
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { createStarterPrompt } from "@/lib/starter-prompt";
import { selfHostedOtpActions } from "@/modules/identity/client";
const BirthTimeRectification = dynamic(
@@ -227,12 +228,16 @@ type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "logout";
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
type DailyStarlanguageApiResponse = {
status?: "ok";
status?: "ok" | "unavailable" | "unauthenticated";
card?: DailyStarlanguageCard;
source?: "calculation_lite";
source?: "agent" | "agent_cache";
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
type DailyStarlanguageState =
| { kind: "pending" }
| { kind: "ready"; card: DailyStarlanguageCard }
| { kind: "unavailable" };
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type ConsultationStatus = {
readonly requestId: string;
@@ -284,13 +289,6 @@ const previewModelCatalog = parsePublicModelCatalog({
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
const dailyStarlanguageCards: DailyStarlanguageCard[] = [
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
];
function greetingForHour(hour: number): string {
if (hour < 11) return "早上好";
if (hour < 18) return "下午好";
@@ -512,23 +510,17 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, re
].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) {
async function fetchDailyStarlanguage(profile: Profile, signal: AbortSignal): Promise<DailyStarlanguageState> {
const response = await fetch("/api/daily-starlanguage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
signal,
});
if (!response.ok) throw new Error("daily_starlanguage_unavailable");
if (!response.ok) return { kind: "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;
if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" };
return { kind: "ready", card: payload.card };
}
function missingProfileStep(profile: Profile): OnboardingStep | null {
@@ -994,7 +986,7 @@ 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 [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
@@ -1043,6 +1035,7 @@ export default function Home() {
const [birthTimeError, setBirthTimeError] = useState("");
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
const [startGreeting, setStartGreeting] = useState("");
const [starterPrompt, setStarterPrompt] = useState(() => createStarterPrompt());
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversation = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
@@ -1050,6 +1043,7 @@ export default function Home() {
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const onboardingPaywallShown = useRef(false);
const starterPromptUnseen = useRef(true);
const composerInput = useRef<HTMLTextAreaElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
const cancellationRequests = useRef(new Map<string, Promise<void>>());
@@ -1230,7 +1224,12 @@ export default function Home() {
const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time";
const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics;
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
? dailyStarlanguage.card.trend
: dailyStarlanguage.kind === "pending"
? "正在结合你的星盘写今天的星语。"
: "今天的星语没能生成,稍后再回来看看;这里不会用通用文案顶替。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
@@ -1724,20 +1723,28 @@ export default function Home() {
useEffect(() => {
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
let cancelled = false;
setDailyStarlanguageCard(null);
void fetchDailyStarlanguage(profile)
.then((card) => {
if (!cancelled) setDailyStarlanguageCard(card);
const controller = new AbortController();
setDailyStarlanguage({ kind: "pending" });
void fetchDailyStarlanguage(profile, controller.signal)
.then((next) => {
if (!controller.signal.aborted) setDailyStarlanguage(next);
})
.catch(() => {
if (!cancelled) setDailyStarlanguageCard(buildDailyStarlanguageCard(profile));
if (!controller.signal.aborted) setDailyStarlanguage({ kind: "unavailable" });
});
return () => {
cancelled = true;
};
return () => controller.abort();
}, [hydrated, profile, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
starterPromptUnseen.current = false;
return;
}
if (starterPromptUnseen.current) return;
starterPromptUnseen.current = true;
setStarterPrompt(createStarterPrompt());
}, [starterHomeVisible]);
useEffect(() => {
if (starterHomeVisible) return;
const container = conversation.current;
@@ -3381,7 +3388,7 @@ export default function Home() {
<section className="starter-hero" aria-labelledby="starter-heading">
<div className="starter-hero-copy">
<p className="starter-greeting">{daypartGreeting}{profile.name.trim()}</p>
<h1 id="starter-heading"></h1>
<h1 id="starter-heading">{starterPrompt}</h1>
<p className="starter-hero-note">{personalChartAvailable
? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。"
: "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
@@ -3399,10 +3406,10 @@ export default function Home() {
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日运势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
<p aria-busy={personalChartAvailable && dailyStarlanguage.kind === "pending"}>{personalChartAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<small>{personalChartAvailable ? dailyStarlanguageAction : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
@@ -3423,7 +3430,6 @@ export default function Home() {
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
</div>
<div className="product-entrypoint-footer">
<small></small>
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>