refactor(chat): extract home helpers, chart library, and starter surfaces
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

page.tsx still owns the chat main chain, but the first product surfaces now
live in their own modules so later splits can land without editing the 4k-line
Home. Source-lock tests follow the moved tokens; the orphan user-data contract
is aligned and added to the quick gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 22:58:02 +08:00
co-authored by Cursor
parent 8558900525
commit 54269fcfcc
32 changed files with 1987 additions and 1466 deletions
+103
View File
@@ -0,0 +1,103 @@
# PROGRESS · 拆分首页巨石组件·第一批(2026-09-01)
工作树:`.worktrees/home-split-20260901`
分支:`codex/home-split-20260901`(跟踪 `origin/staging`)
基线:`origin/staging` @ `85589005`(任务书提交;产品代码与 P2a `ce6a8a7e` 相同)
不要写成 `PROGRESS.md`。本轮未提交、未推送。
## 度量
| 项 | 拆前 `85589005` | 拆后 | 说明 |
| --- | ---: | ---: | --- |
| `frontend/src/app/page.tsx` 行数 | 4766 | 3505 | 目标 ≤3000,未达标 |
| `useState(` | 26 | 26 | 见下方 overlay 说明 |
| `useEffect(` | 20 | 20 | 拉取/重试 effect 仍在 Home |
| `/` 路由 | `○ Static` | `○ Static` | webpack `next build` |
| 首屏 JS gzip -9 | 500262 B = 488.5 KB | 501315 B = 489.6 KB | +1053 B / **+0.21%**(±2% 内) |
| 前端测试 | 2427 | 2427 | fail=0 skipped=0(见验证) |
未达标原因:聊天主链路(composer / transcript / `send` / 停止与恢复 / 引导对话)按任务书本批不动。抽走的是 helpers + 星盘库/合盘面板 + starter/每日星语。剩余约 505 行要等第二批。
`useState` 未下降:`otherProfileDraft` / `editingChartId` / `otherChartRelationship` / `editingSelfChart` 仍留在 `Home`。`AccountDialogOverlay` 在关闭时 `return null`,把独占 state 下移进面板会在关弹窗时丢掉未保存草稿,属于行为变化,本轮禁止。handlers(`saveOtherChart` 等)已随 JSX 进面板,state 经显式 props 传入。
## 任务 0 · 孤儿契约测试
`tests/test_supabase_user_data_contract.py` 对齐现状源码(性质保留:浏览器不直写 `profiles` / `chat_sessions`,账户 PATCH 仍拒数组,合盘仍走 `postPython("/api/chart")`)。任务书写的三处之外,同一文件里还有过期 token,一并换成现码并在上方注释原值:
- 首页不再 `.from("profiles")` / `.from("chat_sessions")`,锁 `fetch("/api/account"` / `fetch("/api/sessions"`
- 账户 PATCH:`accountProfilePatchSchema.safeParse` + `z.object({`(`account-profile-patch.ts`)
- 文案「添加其他人的星盘」
- 合盘:`postPython("/api/chart", selfPayload)` / `partnerPayload`
- 用户消息:`messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }]`
- 中断路径:源码已无 `await persistSession(interruptedSession)`;停止文案「正在停止回答并申请退回本次点数…」
搬家后该文件改读 `_home_surface()`(`page.tsx` + 抽出文件拼接),正则/token 未改。
`scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS` 已列入此文件,注释写明前端搬家轮必须带着它跑。
`pytest tests/test_supabase_user_data_contract.py tests/test_daily_and_rectification_entrypoints.py -q` → 12 passed。
## 任务 1–3 · 搬家
新文件(实现从 `page.tsx` 原样搬出,加 `export`):
| 文件 | 行 | 内容 |
| --- | ---: | --- |
| `frontend/src/lib/home-types.ts` | 254 | 类型、空资料、themes、storage 键常量 |
| `frontend/src/lib/home-profile.ts` | 323 | 地点/资料/标签/`readProfile`/`upsertSelfChart` |
| `frontend/src/lib/home-cloud-sync.ts` | 499 | 云端 fetch、登录跳转、每日星语读写 |
| `frontend/src/components/birth-location-fields.tsx` | 40 | `BirthLocationFields` |
| `frontend/src/components/profile-fields.tsx` | 38 | `ProfileFields` |
| `frontend/src/components/onboarding-chat-message.tsx` | 28 | `OnboardingChatMessage` |
| `frontend/src/components/chart-library-panel.tsx` | 269 | 星盘库 + 合盘卡/历史/他人表单 |
| `frontend/src/components/starter-home.tsx` | 159 | starter hero / 每日星语 / 生时入口 / 主题卡 |
`page.tsx` 仍 `"use client"`、`import "@/app/site-styles"`、`const BirthTimeRectification = dynamic(...)`。新组件未 import 全局样式。无 context/store、无手写 `useCallback`/`useMemo`、未重开 React Compiler。
本人星盘表单仍 `onSubmit={saveProfile}`(面板 prop 名就是 `saveProfile`)。starter 回调 prop 保持原标识符:`startDailyStarlanguageConsultation`、`openRectificationFromHomepage`、`startSuggestedConsultation`。
## 合同测试路径(同一 token,只换读取面)
共用拼接件 `frontend/tests/home-surface.ts`:`page.tsx` + 上表抽出文件。Python 侧 `_home_surface()` / 显式 concat 同理。
| 测试 | 原路径 | 新路径 |
| --- | --- | --- |
| `birth-place-picker.test.ts` | `src/app/page.tsx` | `homeSurface`(`BirthPlacePicker` 在 `birth-location-fields.tsx`) |
| `birth-time-consultation-consent.test.ts` | `page.tsx` | `homeSurface`(`readProfile` 在 `home-profile.ts`) |
| `character-remaining-contract.test.ts` | `page.tsx` | `homeSurface`(姓名字数 `aria-describedby` 在 `profile-fields.tsx`) |
| `chart-library-other-profile.test.ts` | `page.tsx` | `homeSurface` |
| `chat-navigation-a11y-contract.test.ts` | `page.tsx` | `homeSurface` |
| `chat-session-authority.test.ts` | `page.tsx` | `homeSurface` |
| `chat-session-url.test.ts` | `page.tsx` | `homeSurface` |
| `chat-session-write.test.ts` | `page.tsx` | `homeSurface`(`fetch("/api/sessions"` 在 `home-cloud-sync.ts`) |
| `composer-isolation-contract.test.ts` | `page.tsx` | `homeSurface`(`pendingConsultationStorageKey` 在 `home-types.ts`) |
| `consultation-entrypoint.test.ts` | `page.tsx` | `homeSurface`(starter 卡在 `starter-home.tsx`) |
| `consultation-recovery.test.ts` | `page.tsx` | `homeSurface` |
| `daily-starlanguage.test.ts` | `page.tsx` | `homeSurface` |
| `membership-page.test.ts`(home 段) | `page.tsx` | `homeSurface`(`LoginRedirectError` 在 `home-cloud-sync.ts`) |
| `onboarding-presentation.test.ts` | `page.tsx` | `homeSurface` |
| `rectification-agentic-entry.test.ts` | `page.tsx` | `homeSurface` |
| `settings-mvp-contract.test.ts` `renderProfile` | `page.tsx` 切片 | `homeSurface`(切片标记仍在 `page.tsx`) |
| `settings-mvp-contract.test.ts` 星盘库表单 | `page.tsx` 的 `renderChartLibrary`→`renderGeneral` 切片 | `chart-library-panel.tsx`(切片里只剩 `<ChartLibraryPanel />`,token 已搬走) |
| `starter-questions.test.ts` | `page.tsx` | `homeSurface` |
| `tests/test_supabase_user_data_contract.py` | `PAGE.read_text()` | `_home_surface()` |
| `tests/test_daily_and_rectification_entrypoints.py` | `PAGE` | `PAGE` + `starter-home.tsx` + `home-cloud-sync.ts` |
| `tests/test_birth_time_journey_contract.py` 网页引导条 | `page.tsx` | `page.tsx` + `home-profile.ts`(`reported_birth_time` / `active_birth_time`) |
未改断言语义。锁聊天主链路、且 token 仍在 `page.tsx` 的测试继续只读 `page.tsx`(如 `site-style-isolation-contract`、composer/sidebar 壳、`rectification-lazy-loading`)。
## 验证
- `./node_modules/.bin/tsc --noEmit`:通过。
- `npm test`:2427 条。并发下 2 条 Docker 库测 flake(`admin-database` / `model-configuration-security`,`database migration failed` / `Connection terminated unexpectedly`)。`--test-concurrency=1` 重跑这 8 条:pass 8 fail 0。与产品搬家无关。skipped=0。
- `./node_modules/.bin/next build --webpack`:`┌ ○ /`。本树 `node_modules` 仍是指向 `session-url-20260901` 的符号链接,默认 Turbopack 会拒。gzip 口径:预渲染 `index.html` 引用的 `/_next/static/**.js` 去重后 `gzipSync` level 9。基线用同 SHA 干净 worktree 同口径对照。
- 行为抽查:无登录态,未在浏览器点星盘库增删改、合盘、starter 入口、每日星语。由合同测试覆盖。
## 明确未做 / 未修
- 聊天主链路第二批再拆。
- popstate 回默认会话未走 `selectSession`(任务书决策 4)。
- `tests/test_session_management_entrypoints.py::test_archiving_never_calls_the_delete_endpoint` 仍锁已删除的 `setArchivedSessionIds`(P2a 遗留,不在 `CORE_PYTEST_TARGETS`,本轮不顺手修)。
- `tests/test_birth_time_journey_contract.py::test_web_onboarding_uses_the_deterministic_free_journey` 在 mastra 断言 `'entry_mode: entryMode'` 上仍红(与搬家无关、不在 quick gate);本轮只补了 `page.tsx` 侧 token 路径。
- 未改 `.gitea/workflows/**`。未提升 `main`。
+145 -1406
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
"use client";
import { BirthPlacePicker } from "@/components/birth-place-picker";
import type { Profile } from "@/lib/home-types";
export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
return (
<fieldset className="location-fieldset">
<legend>出生地点</legend>
<BirthPlacePicker
value={{
countryCode: value.countryCode,
provinceCode: value.provinceCode,
cityCode: value.cityCode,
districtCode: value.districtCode,
timezoneId: value.timezoneId,
timezoneOffset: value.timezoneOffset,
}}
birthDate={value.date}
birthTime={value.reportedTime || value.time}
onChange={(location) => onChange({
...value,
countryCode: location?.countryCode || "CN",
provinceCode: location?.provinceCode || "",
cityCode: location?.cityCode || "",
districtCode: location?.districtCode || "",
birthPlaceLabel: location?.label || "",
birthPlaceType: location?.placeType || "",
birthPlaceProvider: location?.provider || "",
birthPlaceProviderId: location?.providerPlaceId || "",
timezoneId: location?.timezoneId || "",
timezoneSource: location ? "iana_historical" : "",
latitude: location?.latitude ?? null,
longitude: location?.longitude ?? null,
timezoneOffset: location?.timezoneOffset ?? null,
})}
/>
</fieldset>
);
}
@@ -0,0 +1,269 @@
"use client";
import type { Dispatch, FormEvent, SetStateAction } from "react";
import { ProfileFields } from "@/components/profile-fields";
import { activeChartStorageKey, deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync";
import {
chartRelationshipLabel,
formatChartUpdatedAt,
missingOtherProfileStep,
profileBirthTimeLabel,
profileBirthTimeStatusLabel,
profilePlaceLabel,
readProfile,
upsertSelfChart,
} from "@/lib/home-profile";
import { emptyProfile, timestamp, type Account, type ChartLibraryRecord, type ChartRelationship, type Profile, type SynastryRelationshipType, type SynastryReportCard } from "@/lib/home-types";
export type ChartLibraryPanelProps = {
readonly account: Account | null;
readonly accountId: string | undefined;
readonly chartLibrary: ChartLibraryRecord[];
readonly setChartLibrary: Dispatch<SetStateAction<ChartLibraryRecord[]>>;
readonly profile: Profile;
readonly profileDraft: Profile;
readonly setProfileDraft: Dispatch<SetStateAction<Profile>>;
readonly setProfile: Dispatch<SetStateAction<Profile>>;
readonly profileSaving: boolean;
readonly profileNotice: string;
readonly setProfileNotice: Dispatch<SetStateAction<string>>;
readonly setAccountError: Dispatch<SetStateAction<string>>;
readonly editingSelfChart: boolean;
readonly setEditingSelfChart: Dispatch<SetStateAction<boolean>>;
readonly otherProfileDraft: Profile;
readonly setOtherProfileDraft: Dispatch<SetStateAction<Profile>>;
readonly otherChartRelationship: Exclude<ChartRelationship, "self">;
readonly setOtherChartRelationship: Dispatch<SetStateAction<Exclude<ChartRelationship, "self">>>;
readonly editingChartId: string | null;
readonly setEditingChartId: Dispatch<SetStateAction<string | null>>;
readonly synastryRelationshipType: SynastryRelationshipType;
readonly setSynastryRelationshipType: Dispatch<SetStateAction<SynastryRelationshipType>>;
readonly synastryPendingId: string | null;
readonly synastryReportCard: SynastryReportCard | null;
readonly setSynastryReportCard: Dispatch<SetStateAction<SynastryReportCard | null>>;
readonly synastryHistory: SynastryReportCard[];
readonly activeChartId: string;
readonly setActiveChartId: Dispatch<SetStateAction<string>>;
readonly saveProfile: (event: FormEvent<HTMLFormElement>) => void;
readonly draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => void;
};
export function ChartLibraryPanel({
account,
accountId,
chartLibrary,
setChartLibrary,
profile,
profileDraft,
setProfileDraft,
setProfile,
profileSaving,
profileNotice,
setProfileNotice,
setAccountError,
editingSelfChart,
setEditingSelfChart,
otherProfileDraft,
setOtherProfileDraft,
otherChartRelationship,
setOtherChartRelationship,
editingChartId,
setEditingChartId,
synastryRelationshipType,
setSynastryRelationshipType,
synastryPendingId,
synastryReportCard,
setSynastryReportCard,
synastryHistory,
activeChartId,
setActiveChartId,
saveProfile,
draftSynastryQuestionFromChart,
}: ChartLibraryPanelProps) {
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship };
if (missingOtherProfileStep(nextProfile)) {
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
return;
}
if (!accountId) return;
const record: ChartLibraryRecord = {
id: editingChartId || globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
relationship: otherChartRelationship,
updatedAt: timestamp(),
};
try {
const saved = editingChartId
? await updateCloudChartProfile(record)
: await saveCloudChartProfile(record);
const selfProfile = account ? readProfile(account.profile) : profile;
setChartLibrary((current) => {
const others = editingChartId
? current.map((item) => item.id === saved.id ? saved : item)
: [...current, saved];
return upsertSelfChart(others, selfProfile);
});
setOtherProfileDraft(emptyProfile);
setOtherChartRelationship("other");
setEditingChartId(null);
setAccountError("");
setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
} catch {
setProfileNotice("保存失败,请重试");
setAccountError("");
}
}
function editOtherChart(record: ChartLibraryRecord) {
if (record.role !== "other") return;
setOtherProfileDraft(record.profile);
setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship);
setEditingChartId(record.id);
setAccountError("");
setProfileNotice("");
}
async function deleteOtherChart(recordId: string) {
if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return;
try {
await deleteCloudChartProfile(recordId);
} catch {
setProfileNotice("删除失败,请重试");
setAccountError("");
return;
}
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
if (activeChartId === recordId) {
setActiveChartId("self");
localStorage.setItem(activeChartStorageKey(accountId), "self");
}
return next;
});
setAccountError("");
setProfileNotice("已从云端星盘库删除。");
}
function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving || !accountId) return;
setActiveChartId(record.id);
localStorage.setItem(activeChartStorageKey(accountId), record.id);
setProfile(record.profile);
setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。");
}
return (
<div className="chart-library-panel" aria-label="星盘资料管理">
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b>我的星盘</b><small>当前账号的默认资料</small></div>
<span className="chart-library-count">{chartLibrary.filter((record) => record.role === "self").length}</span>
</div>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div className="chart-library-item-main">
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge is-self">本人</span><span className="chart-default-badge">当前默认</span></div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => { setProfileDraft(record.profile); setEditingSelfChart(true); }} disabled={profileSaving || editingChartId !== null}>编辑本人资料</button>
</div>
</article>
))}
{editingSelfChart && (
<form className="profile-form chart-library-form" onSubmit={saveProfile}>
<div className="section-heading"><b>编辑本人星盘</b><small>这份资料会用于默认解盘与新对话。</small></div>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="self-profile-name" />
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => { setEditingSelfChart(false); setProfileDraft(profile); }}>取消编辑</button>
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : "保存本人资料"}</button>
</div>
</form>
)}
{chartLibrary.filter((record) => record.role === "self").length === 0 && <p className="empty-library-copy">请先在个人资料中补全你的出生资料。</p>}
</div>
<div className="chart-library-group">
<div className="chart-library-group-heading">
<div><b>其他人的星盘</b><small>亲友、伴侣或客户资料</small></div>
<span className="chart-library-count">{chartLibrary.filter((record) => record.role === "other").length}</span>
</div>
{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 className="chart-library-item-main">
<div className="chart-library-item-title"><strong>{record.profile.name || "未命名"}</strong><span className="chart-role-badge">{chartRelationshipLabel(record.relationship)}</span></div>
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
</div>
<div className="chart-library-actions">
<select aria-label={`${record.profile.name || "其他人"}的关系类型`} value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance">婚恋</option>
<option value="business">商业合作</option>
<option value="family">亲友/家庭</option>
<option value="general">其他关系</option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => editOtherChart(record)} disabled={profileSaving || editingChartId !== null}>编辑</button>
<button className="button-secondary" type="button" onClick={() => makeDefaultChart(record)} disabled={profileSaving}>设为默认</button>
<button className="button-secondary" type="button" onClick={() => void 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>{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}</b><small>用于合盘、亲友盘或客户盘。</small></div>
<label>
<span>关系</span>
<select value={otherChartRelationship} onChange={(event) => setOtherChartRelationship(event.target.value as Exclude<ChartRelationship, "self">)}>
<option value="partner">伴侣</option>
<option value="family">家人</option>
<option value="friend">朋友</option>
<option value="client">客户</option>
<option value="other">其他</option>
</select>
</label>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<div className="dialog-actions">
{editingChartId && <button className="button-secondary" type="button" onClick={() => { setEditingChartId(null); setOtherProfileDraft(emptyProfile); setOtherChartRelationship("other"); }}>取消编辑</button>}
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : editingChartId ? "保存修改" : "添加到星盘库"}</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar } from "@/components/chat-message-row";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
import type { Message } from "@/lib/home-types";
export function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) {
const visibleText = streaming ? text.slice(0, length) : text;
const protectedVisibleText = protectOnboardingPhrases(visibleText);
return (
<article className={`message message-${role} onboarding-message${phraseSafe ? " is-phrase-safe" : ""}`} aria-label={role === "assistant" ? "Jyotisha" : "你"}>
{role === "assistant" && <AgentAvatar />}
<div className="message-content">
<div className="message-bubble">
{role === "assistant" ? (
streaming ? (
<>
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={protectedVisibleText} /></div>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{length >= text.length ? text : ""}</span>
</>
) : <ChatMessageContent text={protectedVisibleText} />
) : <p>{protectedVisibleText}</p>}
</div>
</div>
</article>
);
}
@@ -0,0 +1,38 @@
"use client";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthLocationFields } from "@/components/birth-location-fields";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import { applyBirthTimeDraftPatch } from "@/lib/birth-time-intake-model";
import { invalidateCandidateAfterLocationChange } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
export function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`;
return (
<>
<label>
<span className="character-remaining-row">
如何称呼你
<CharacterRemaining id={nameRemainingId} length={value.name.length} maxLength={80} />
</span>
<input
id={nameInputId}
required
autoComplete="name"
maxLength={80}
placeholder="例如:林遥"
value={value.name}
aria-describedby={characterRemainingVisible(value.name.length, 80) ? nameRemainingId : undefined}
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
<BirthLocationFields
value={value}
onChange={(next) => onChange(invalidateCandidateAfterLocationChange(value, next))}
/>
</>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import type { DailyStarlanguageState, Theme } from "@/lib/home-types";
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
export type StarterHomeTheme = {
readonly id: string;
readonly label: string;
readonly prompt: string;
};
export type StarterHomeSuggestion = {
readonly theme: Theme;
readonly text: string;
};
export type StarterHomeProps = {
readonly starterGreeting: { readonly salutation: string; readonly question: string };
readonly dailyStarlanguage: DailyStarlanguageState;
readonly natalMinuteAvailable: boolean;
readonly dailyStarlanguageQuestion: string;
readonly dailyStarlanguageTrend: string;
readonly dailyStarlanguageAction: string;
readonly dailyStarlanguageBusy: boolean;
readonly productEntrypointsDisabled: boolean;
readonly startDailyStarlanguageConsultation: () => void;
readonly rectificationCardLabel: string;
readonly rectificationCardAction: string;
readonly rectificationLoading: boolean;
readonly rectificationMutationPending: boolean;
readonly openRectificationFromHomepage: () => void;
readonly rectificationEntrySummary: RectificationEntrySummary | null;
readonly rectificationError: string;
readonly rectificationSurfaceOpen: boolean;
readonly rectificationErrorMessage: string;
readonly starterThemes: readonly StarterHomeTheme[];
readonly starterSuggestions: readonly StarterHomeSuggestion[];
readonly startSuggestedConsultation: (text: string, theme: Theme) => void;
readonly onboardingError: string;
};
export function StarterHome({
starterGreeting,
natalMinuteAvailable,
dailyStarlanguage,
dailyStarlanguageQuestion,
dailyStarlanguageTrend,
dailyStarlanguageAction,
dailyStarlanguageBusy,
productEntrypointsDisabled,
startDailyStarlanguageConsultation,
rectificationCardLabel,
rectificationCardAction,
rectificationLoading,
rectificationMutationPending,
openRectificationFromHomepage,
rectificationEntrySummary,
rectificationError,
rectificationSurfaceOpen,
rectificationErrorMessage,
starterThemes,
starterSuggestions,
startSuggestedConsultation,
onboardingError,
}: StarterHomeProps) {
return (
<div className="starter-list starter-workbench" aria-label="Jyotisha 推荐的初始问题">
<section className="starter-hero" aria-labelledby="starter-heading">
<div className="starter-hero-copy">
<p className="starter-greeting">{starterGreeting.salutation}</p>
<h1 id="starter-heading">{starterGreeting.question}</h1>
</div>
</section>
<section className="product-entrypoints" aria-label="常用占星入口">
<article className="daily-starlanguage-card product-entrypoint-card" aria-labelledby="daily-starlanguage-title">
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={startDailyStarlanguageConsultation}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
<p
aria-busy={dailyStarlanguageBusy}
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
{natalMinuteAvailable
? (dailyStarlanguageAction ? <small>{dailyStarlanguageAction}</small> : null)
: <small>不支持的个人判断会明确说明,不会补造出生时间。</small>}
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={rectificationCardLabel}
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending}
onClick={() => void openRectificationFromHomepage()}
/>
<div className="product-entrypoint-copy">
<h2 id="birth-rectification-title">生时校正</h2>
<p>{rectificationEntrySummary?.hasResumableCase
? "新建一段独立校正;未完成的记录仍可从左侧历史会话继续。"
: rectificationCardAction === "restart"
? "上一次校正已经完成,可以基于最新资料再次校正。"
: "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}</p>
</div>
<div className="product-entrypoint-footer">
<span className="product-entrypoint-action" aria-hidden="true">{rectificationCardLabel} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
</section>
{rectificationError && !rectificationSurfaceOpen && (
<p className="form-error rectification-entry-error" role="alert">
{rectificationErrorMessage}
</p>
)}
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
<h2 id="starter-themes-heading">从一个主题开始</h2>
<p>{natalMinuteAvailable
? "选择一个你现在想解决的问题。"
: "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
</div>
<div className="starter-theme-accordion">
{starterSuggestions.map((item) => {
const theme = starterThemes.find((candidate) => candidate.id === item.theme);
return (
<button
className="starter-theme-card"
key={`${item.theme}-${item.text}`}
type="button"
aria-label={`${theme?.label || "开始"}:${item.text}`}
disabled={productEntrypointsDisabled}
onClick={() => void startSuggestedConsultation(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>
);
})}
</div>
</section>
{onboardingError && <p className="starter-note">个性化问题暂时不可用,已显示安全的默认问题。</p>}
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan";
import {
parsePublicModelCatalog,
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry";
import {
timestamp,
undoWindowMs,
uuidPattern,
type Account,
type ChartLibraryApiRecord,
type ChartLibraryRecord,
type ChatProfileBinding,
type ChatSession,
type ChatSessionType,
type ConsultationStatus,
type DailyStarlanguageApiResponse,
type DailyStarlanguageState,
type Message,
type SessionReadResult,
type StoredDailyStarlanguage,
type StoredPendingConsultation,
type SynastryReportApiRecord,
type SynastryReportCard,
} from "@/lib/home-types";
export function readStoredPendingConsultation(
raw: string | null,
sessionIds: Iterable<string>,
): StoredPendingConsultation | null {
if (!raw) return null;
try {
const parsedPending = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsedPending.sessionId !== "string"
|| typeof parsedPending.requestId !== "string"
|| !uuidPattern.test(parsedPending.sessionId)
|| !uuidPattern.test(parsedPending.requestId)) {
return null;
}
let sessionKnown = false;
for (const sessionId of sessionIds) {
if (sessionId === parsedPending.sessionId) {
sessionKnown = true;
break;
}
}
if (!sessionKnown) return null;
return {
sessionId: parsedPending.sessionId,
requestId: parsedPending.requestId,
question: typeof parsedPending.question === "string" ? parsedPending.question : "",
theme: normalizeConsultationDomain(parsedPending.theme),
entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null,
};
} catch {
return null;
}
}
export function createSession(
modelId: string,
sessionType: ChatSessionType = "consultation",
chartBinding: ChatProfileBinding = { chartProfileId: null, chartProfileName: null, chartProfileRole: null },
): ChatSession {
return {
id: globalThis.crypto.randomUUID(),
title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话",
theme: "general",
modelId,
messages: [],
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
pinned: false,
archivedAt: null,
messagesHydrated: true,
...chartBinding,
};
}
export function activeChartStorageKey(accountId: string) {
return `jyotisha_active_chart:${accountId}`;
}
export function dailyStarlanguageStorageKey(accountId: string) {
return `jyotisha_daily_starlanguage:${accountId}`;
}
export function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") {
return `jyotisha-session-controls:${accountId}:${kind}`;
}
export function discardLegacyCloudMirrorKeys(accountId: string) {
localStorage.removeItem(`jyotisha_chart_library:${accountId}`);
localStorage.removeItem(`jyotisha_synastry_history:${accountId}`);
}
export function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown;
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
export function clearLegacySessionControlKeys(accountId: string) {
localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned"));
localStorage.removeItem(sessionControlsStorageKey(accountId, "archived"));
}
export function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] {
const pinnedKey = sessionControlsStorageKey(accountId, "pinned");
const archivedKey = sessionControlsStorageKey(accountId, "archived");
if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) {
return sessions;
}
const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned"));
const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived"));
const next = sessions.map((session) => ({
...session,
pinned: session.pinned || pinnedIds.has(session.id),
archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null),
}));
void Promise.allSettled(next.flatMap((session, index) => {
const previous = sessions[index];
if (!previous) return [];
const patch: { pinned?: boolean; archived_at?: string | null } = {};
if (session.pinned !== previous.pinned) patch.pinned = session.pinned;
if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt;
if (patch.pinned === undefined && patch.archived_at === undefined) return [];
return [writeChatSession(session.id, patch, "update")];
}));
clearLegacySessionControlKeys(accountId);
return next;
}
export function readStoredDailyStarlanguage(accountId: string): StoredDailyStarlanguage | null {
try {
const parsed = JSON.parse(localStorage.getItem(dailyStarlanguageStorageKey(accountId)) || "null") as StoredDailyStarlanguage | null;
if (!parsed?.day || !parsed.fingerprint || !parsed.card?.trend || !parsed.card?.action) return null;
return parsed;
} catch {
return null;
}
}
export function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) {
localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored));
}
export 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(),
};
}
export 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[];
}
export 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;
}
export function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
const relationship = record.role === "self" ? "self" : record.profile.chartRelationship || "other";
return {
id: record.role === "self" ? "self" : record.id,
role: record.role,
profile: { ...record.profile, chartRelationship: relationship },
relationship,
updatedAt: Date.parse(record.updated_at || "") || timestamp(),
};
}
export 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);
}
export async function saveCloudChartProfile(record: ChartLibraryRecord) {
const response = await fetch("/api/chart-profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
role: record.role,
profile: record.profile,
}),
});
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_save_failed");
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
}
export async function updateCloudChartProfile(record: ChartLibraryRecord) {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(record.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: record.profile }),
});
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_update_failed");
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
}
export 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");
}
export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<DailyStarlanguageState> {
const response = await fetch("/api/daily-starlanguage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
signal,
});
if (!response.ok) return { kind: "unavailable" };
const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null;
if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" };
return { kind: "ready", card: payload.card };
}
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
const sessions = value.flatMap((item): ChatSession[] => {
if (!item || typeof item !== "object") return [];
const session = item as Partial<ChatSession> & {
model_id?: unknown;
rectification_case_id?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
session_type?: unknown;
updated_at?: unknown;
archived_at?: unknown;
};
const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages");
const messages: Message[] = Array.isArray(session.messages)
? session.messages.flatMap((message) => {
if (!message || typeof message !== "object") return [];
const stored = message as Message;
if ((stored.role !== "user" && stored.role !== "assistant") || typeof stored.text !== "string") {
return [];
}
const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim()
? stored.thinkingText.slice(0, 4000)
: undefined;
const thinkingSections = parsePublicThinkingSections(stored.thinkingSections);
return [{
role: stored.role,
text: stored.text.slice(0, 12000),
...(thinkingText ? { thinkingText } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}),
...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}),
...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}),
}];
})
: [];
if (typeof session.id !== "string") return [];
const savedModelId = session.model_id ?? session.modelId;
const selection = catalog
? resolveSessionModelId(savedModelId, catalog)
: { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false };
if (catalog && selection.fellBack) fallbackSessionIds.push(session.id);
return [{
id: session.id,
title: typeof session.title === "string" ? session.title.slice(0, 48) : "新对话",
theme: normalizeConsultationDomain(session.theme) ?? "general",
modelId: selection.modelId,
messages,
sessionType: session.session_type === "birth_time_rectification"
? "birth_time_rectification"
: "consultation",
rectificationCaseId: typeof session.rectification_case_id === "string"
? session.rectification_case_id
: null,
chartProfileId: typeof session.chart_profile_id === "string" ? session.chart_profile_id : null,
chartProfileName: typeof session.chart_profile_name === "string" ? session.chart_profile_name : null,
chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other"
? session.chart_profile_role
: null,
pinned: session.pinned === true,
archivedAt: typeof session.archived_at === "string" && session.archived_at
? session.archived_at
: typeof session.archivedAt === "string" && session.archivedAt
? session.archivedAt
: null,
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
? Date.parse(session.updated_at)
: timestamp(),
messagesHydrated: messagesPresent,
}];
});
return { sessions, fallbackSessionIds };
}
export function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] {
const next = { ...detailed, messagesHydrated: true };
if (current.some((session) => session.id === next.id)) {
return current.map((session) => (session.id === next.id ? { ...session, ...next } : session));
}
return [next, ...current];
}
export function friendlyError(message: string) {
return (
message.includes("数据库配置缺失")
|| (message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL")))
)
? "数据库尚未配置"
: message;
}
export function payloadMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== "object") return fallback;
const data = payload as Record<string, unknown>;
const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined;
return friendlyError(message || fallback);
}
export function payloadCode(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const code = (payload as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
export class CancellationResponseError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "CancellationResponseError";
this.status = status;
}
}
export class ConsultationResponseError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.name = "ConsultationResponseError";
this.status = status;
this.code = code;
}
}
export class ConsultationStatusError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ConsultationStatusError";
this.status = status;
}
}
export class LoginRedirectError extends Error {
constructor() {
super("Redirecting to login");
this.name = "LoginRedirectError";
}
}
export function redirectToLogin(): never {
persistLoginSessionReturn();
window.location.replace("/login");
throw new LoginRedirectError();
}
export function waitForUndoWindow(signal: AbortSignal) {
return new Promise<void>((resolve) => {
const finish = () => {
window.clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
const timer = window.setTimeout(finish, undoWindowMs);
signal.addEventListener("abort", finish, { once: true });
});
}
export async function fetchAccount(signal?: AbortSignal): Promise<Account> {
const response = await fetch("/api/account", { signal, cache: "no-store" });
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息"));
return payload as Account;
}
export async function fetchModelCatalog(signal?: AbortSignal) {
const response = await fetch("/api/models", { signal, cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型"));
return parsePublicModelCatalog(payload);
}
export async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
}
export async function fetchSessionDetail(
sessionId: string,
catalog: PublicLanguageModelCatalog | null,
signal?: AbortSignal,
): Promise<ChatSession | null> {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
signal,
cache: "no-store",
});
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
const sessionValue = payload && typeof payload === "object"
? (payload as { session?: unknown }).session
: null;
return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null;
}
export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
const status = payload as Partial<ConsultationStatus>;
if (typeof status.requestId !== "string"
|| typeof status.sessionId !== "string"
|| (requestId && status.requestId !== requestId)
|| (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) {
throw new Error("后台回答状态无效");
}
return status as ConsultationStatus;
}
export async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise<ConsultationStatus> {
const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, {
signal,
cache: "no-store",
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new ConsultationStatusError(
response.status,
payloadMessage(payload, "暂时无法恢复后台回答"),
);
}
const status = parseConsultationStatus(payload, requestId);
if (status.sessionId !== sessionId) throw new Error("后台回答状态无效");
return status;
}
export async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise<ConsultationStatus | null> {
const response = await fetch("/api/consult/status", { signal, cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (response.status === 404) return null;
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答"));
const status = parseConsultationStatus(payload);
if (status.status !== "reserved") throw new Error("后台回答状态无效");
return status;
}
export async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ model_id: modelId }),
signal,
});
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。"));
}
+323
View File
@@ -0,0 +1,323 @@
import type { ProvinceNode } from "@/data/china-locations";
import {
declaredBirthInputChanged,
describeBirthTimeDraft,
hydrateDeclaredWindowDraft,
isBirthTimeDraftReady,
isDeclaredBirthProfileComplete,
normalizePersistedBirthDate,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
import {
birthLocationKeys,
china,
emptyProfile,
presetOnboardingMessage,
timestamp,
type BirthPlace,
type ChartLibraryRecord,
type ChartRelationship,
type ChatProfileBinding,
type ChatSession,
type Message,
type OnboardingStep,
type Profile,
type SynastryRelationshipType,
} from "@/lib/home-types";
export function findProvince(code: string) {
return china.provinces.find((province) => province.code === code);
}
export function findCity(province: ProvinceNode | undefined, code: string) {
return province?.cities.find((city) => city.code === code);
}
export function selectedBirthPlace(profile: Profile): BirthPlace | null {
if (profile.birthPlaceLabel
&& Number.isFinite(profile.latitude)
&& Number.isFinite(profile.longitude)
&& profile.timezoneId.trim()
&& (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) {
return {
label: profile.birthPlaceLabel,
lat: profile.latitude as number,
lon: profile.longitude as number,
tz: profile.timezoneOffset,
timezoneId: profile.timezoneId,
};
}
const province = findProvince(profile.provinceCode);
const city = findCity(province, profile.cityCode);
if (!province || !city) return null;
const district = city.districts.find((item) => item.code === profile.districtCode);
if (city.districts.length > 0 && !district) return null;
const location = district ?? city;
const label = [china.name, province.name, city.name, district?.name]
.filter((name, index, names) => Boolean(name) && names.indexOf(name) === index)
.join(" · ");
return {
label,
lat: location.center[1],
lon: location.center[0],
tz: china.timezone,
timezoneId: "Asia/Shanghai",
};
}
export function profileReadyForLibrary(profile: Profile) {
return !missingProfileStep(profile);
}
export function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() };
}
export function chartSnapshotForSession(
chartId: string,
library: readonly ChartLibraryRecord[],
fallbackProfile: Profile,
): ChatProfileBinding {
const record = library.find((item) => item.id === chartId);
if (record) {
return {
chartProfileId: record.id,
chartProfileName: record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料"),
chartProfileRole: record.role,
};
}
if (chartId === "self") {
return { chartProfileId: "self", chartProfileName: fallbackProfile.name.trim() || "我", chartProfileRole: "self" };
}
return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null };
}
export function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) {
if (!session.chartProfileId) return "未关联资料";
const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId);
const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料");
return current ? name : `资料已删除 · ${name}`;
}
export function sessionSidebarTitle(session: ChatSession, library: readonly ChartLibraryRecord[]) {
return `${sessionChartLabel(session, library)} · ${session.title || "新对话"}`;
}
export 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];
}
export function profilePlaceLabel(profile: Profile) {
return selectedBirthPlace(profile)?.label || "地点未完整";
}
export function profileBirthTimeLabel(profile: Profile) {
if (profile.time) return profile.time;
if (profile.birthTimePeriod) return `${profile.birthTimePeriod}(时分待确认)`;
return "出生时间待补全";
}
export function profileBirthTimeStatusLabel(profile: Profile) {
if (profile.birthTimeStatus === "confirmed") return "时间已确认";
if (profile.birthTimeStatus === "candidate" || profile.birthTimeStatus === "accepted") return "时间为候选";
if (profile.birthTimeStatus === "rectifying" || profile.birthTimeStatus === "assessing") return "正在评估时间";
return "时间待确认";
}
export function chartRelationshipLabel(relationship: ChartRelationship) {
return relationship === "partner" ? "伴侣" : relationship === "family" ? "家人" : relationship === "friend" ? "朋友" : relationship === "client" ? "客户" : relationship === "self" ? "本人" : "其他";
}
export function formatChartUpdatedAt(updatedAt: number) {
if (!Number.isFinite(updatedAt) || updatedAt <= 0) return "刚刚更新";
return `更新于 ${new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(updatedAt))}`;
}
export function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) {
const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋";
const evidenceRequest = relationshipType === "business"
? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。"
: relationshipType === "romance"
? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。"
: "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。";
return [
`请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`,
`我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`,
`对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`,
evidenceRequest,
].join("\n");
}
export function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!isDeclaredBirthProfileComplete(profile)) return "birth";
if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place";
return null;
}
export function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null {
if (!profile.name.trim()) return "name";
if (!isBirthTimeDraftReady(profile)) return "birth";
if (!selectedBirthPlace(profile)) return "place";
return null;
}
export function birthQuestion(name: string) {
return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`;
}
export function formatBirthMoment(profile: Profile) {
return describeBirthTimeDraft(profile);
}
export function placeQuestion(profile: Profile) {
return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`;
}
export function completedOnboardingMessage(name: string) {
return `${name},我们可以开始了。你可以从下面三个方向选择,也可以直接告诉我现在最想问的事。`;
}
export function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] {
const name = profile.name.trim();
const birthPlace = selectedBirthPlace(profile);
if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return [];
return [
{ role: "assistant", text: presetOnboardingMessage },
{ role: "user", text: name },
{ role: "assistant", text: birthQuestion(name) },
{ role: "user", text: formatBirthMoment(profile) },
{ role: "assistant", text: placeQuestion(profile) },
{ role: "user", text: birthPlace.label },
{ role: "assistant", text: greeting || completedOnboardingMessage(name) },
];
}
export function readProfile(value: unknown): Profile {
if (!value || typeof value !== "object") return emptyProfile;
const profile = value as Partial<Profile> & {
birth_date?: unknown;
birth_time?: unknown;
reported_birth_time?: unknown;
active_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
declared_window_start?: unknown;
declared_window_end?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
birth_time_status?: unknown;
rectification_case_id?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
birth_place_label?: unknown;
birth_place_type?: unknown;
birth_place_provider?: unknown;
birth_place_provider_id?: unknown;
timezone_id?: unknown;
timezone_source?: unknown;
latitude?: unknown;
longitude?: unknown;
timezone_offset?: unknown;
chartRelationship?: unknown;
};
const date = normalizePersistedBirthDate(
typeof profile.birth_date === "string" ? profile.birth_date : profile.date,
);
const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time;
const time = typeof profile.active_birth_time === "string"
? profile.active_birth_time.slice(0, 5)
: legacyTime;
const persistedReportedTime = typeof profile.reported_birth_time === "string"
? profile.reported_birth_time.slice(0, 5)
: "";
const knownSources: readonly BirthTimeSource[] = [
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
];
const source = knownSources.find((item) => item === profile.birth_time_source)
?? (time ? "legacy_import" : "");
const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : "";
const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : "";
const declaredWindow = hydrateDeclaredWindowDraft({
period,
start: windowStart,
end: windowEnd,
});
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode;
const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode;
const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode;
const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode;
const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel;
const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType;
const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider;
const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId;
const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId;
const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource;
const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null;
const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null;
const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset)
? profile.timezone_offset
: typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset)
? profile.timezoneOffset
: null;
const chartRelationships: readonly ChartRelationship[] = ["self", "partner", "family", "friend", "client", "other"];
const chartRelationship = chartRelationships.find((item) => item === profile.chartRelationship);
return {
name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "",
date: typeof date === "string" ? date : "",
time: typeof time === "string" ? time : "",
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
birthTimeSource: source,
birthTimePeriod: declaredWindow.birthTimePeriod,
declaredWindowStart: declaredWindow.declaredWindowStart,
declaredWindowEnd: declaredWindow.declaredWindowEnd,
birthTimeClue: "",
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
birthTimeStatus: status,
rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "",
countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN",
provinceCode: typeof provinceCode === "string" ? provinceCode : "",
cityCode: typeof cityCode === "string" ? cityCode : "",
districtCode: typeof districtCode === "string" ? districtCode : "",
birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "",
birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "",
birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "",
birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "",
timezoneId: typeof timezoneId === "string" ? timezoneId : "",
timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "",
latitude,
longitude,
timezoneOffset,
...(chartRelationship ? { chartRelationship } : {}),
};
}
export function birthProfileDeclarationChanged(current: Profile, next: Profile) {
return declaredBirthInputChanged(current, next)
|| birthLocationKeys.some((key) => current[key] !== next[key]);
}
export function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile {
const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]);
if (!locationChanged
|| current.birthTimeStatus === "confirmed"
|| (current.birthTimeStatus !== "candidate" && !current.time)) return next;
return { ...next, time: "", birthTimeStatus: "reported" };
}
export function isProfileComplete(profile: Profile) {
return missingProfileStep(profile) === null;
}
+254
View File
@@ -0,0 +1,254 @@
import type { BeamAvatar } from "@/lib/beam-avatar";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import type { BirthTimeDraft } from "@/lib/birth-time-intake-model";
import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view";
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
import type { ChatReplyPhase } from "@/lib/chat-reply-announcement";
import type { ReplyTheme } from "@/lib/agent-reply";
import { parsePublicModelCatalog } from "@/lib/public-models";
import { defaultGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
import { chinaLocations } from "@/data/china-locations";
export type Theme = ReplyTheme;
export type Message = ChatMessage;
export type Profile = BirthTimeDraft & {
name: string;
countryCode: string;
provinceCode: string;
cityCode: string;
districtCode: string;
birthPlaceLabel: string;
birthPlaceType: string;
birthPlaceProvider: string;
birthPlaceProviderId: string;
timezoneId: string;
timezoneSource: string;
latitude: number | null;
longitude: number | null;
timezoneOffset: number | null;
rectificationCaseId: string;
chartRelationship?: ChartRelationship;
};
export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other";
export type ChartLibraryRecord = {
id: string;
role: "self" | "other";
profile: Profile;
relationship: ChartRelationship;
updatedAt: number;
};
export type SynastryRelationshipType = "romance" | "business" | "family" | "general";
export type ChartLibraryApiRecord = {
id: string;
role: "self" | "other";
profile: Profile;
updated_at?: string;
};
export type SynastryReportCard = {
id: string;
partnerName: string;
score?: number;
maxScore?: number;
assessment?: string;
headline?: string;
scoreBand?: string;
strengths?: string[];
risks?: string[];
nextEvidence?: string[];
createdAt: number;
};
export type SynastryReportApiRecord = {
id: string;
partner_name?: string;
report?: SynastryReportCard;
created_at?: string;
};
export type ChatSessionType = "consultation" | "birth_time_rectification";
export type ChatProfileBinding = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
};
export type ChatSession = {
id: string;
title: string;
theme: Theme;
modelId: string;
messages: Message[];
updatedAt: number;
sessionType: ChatSessionType;
rectificationCaseId: string | null;
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
pinned: boolean;
archivedAt: string | null;
messagesHydrated: boolean;
};
export type RequestError = { sessionId: string; message: string };
export type ReplyOutcome = {
readonly sessionId: string;
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
readonly replyOrdinal: number;
};
export type StreamingReply = {
sessionId: string;
text: string;
activity?: AgentActivityView;
thinkingText?: string;
thinkingSections?: PublicThinkingSection[];
timeline?: readonly ConsultationTimelineRow[];
};
export type BirthPlace = {
label: string;
lat: number;
lon: number;
tz: number | null;
timezoneId: string;
};
export type Account = {
user: { id: string; email: string | null };
avatar: BeamAvatar | null;
credits: number;
isAdmin: boolean;
adminUrl: string | null;
rectificationPriceCredits: number;
activeSubscription: {
id: string;
status: string;
startsAt: string;
endsAt: string;
productCode: string;
productVersion: number;
product: { name?: string; productType?: string } | null;
entitlements: unknown;
} | null;
hasConfirmedBirthTime: boolean;
hasUsableBirthTime: boolean;
profile: unknown;
};
export type OnboardingStep = "name" | "birth" | "place" | "rectification";
export type AccountDialog = "profile" | "chart-library" | "general" | "logout";
export type DailyStarlanguageCard = { trend: string; action: string; caution: string };
export type DailyStarlanguageApiResponse = {
status?: "ok" | "unavailable" | "unauthenticated";
card?: DailyStarlanguageCard;
source?: "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache";
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
export type DailyStarlanguageState =
| { kind: "pending" }
| { kind: "ready"; card: DailyStarlanguageCard }
| { kind: "unavailable" };
export type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
export type ConsultationStatus = {
readonly requestId: string;
readonly sessionId: string;
readonly status: "reserved" | "completed" | "cancelled";
readonly responseMessage?: unknown;
readonly updatedAt?: string;
};
export type PendingConsultation = {
readonly requestId: string;
readonly sessionId: string;
readonly question: string;
readonly entrypoint: ConsultationEntrypoint | null;
readonly theme: Theme;
readonly previousSession: ChatSession;
readonly optimisticSession: ChatSession;
readonly previousOnboardingState: boolean;
readonly controller: AbortController;
readonly cancelled: boolean;
readonly phase: "undo" | "streaming" | "recovering";
readonly partialReply: string;
};
export const undoWindowMs = 2_500;
export const pendingConsultationStorageKey = "jyotisha.pending-consultation";
export const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type StoredPendingConsultation = {
readonly sessionId: string;
readonly requestId: string;
readonly question: string;
readonly theme: Theme | null;
readonly entrypoint: ConsultationEntrypoint | null;
};
export const china = chinaLocations.country;
export const themes = defaultGuidedJyotishTopics;
export const accountDialogTitles = {
profile: "个人资料",
"chart-library": "星盘资料",
general: "通用设置",
logout: "退出登录?",
} as const satisfies Record<AccountDialog, string>;
export const accountDialogClasses = {
profile: "profile-modal",
"chart-library": "chart-library-modal",
general: "general-modal",
logout: "logout-modal",
} as const satisfies Record<AccountDialog, string>;
export const previewModelCatalog = parsePublicModelCatalog({
defaultModelId: "deepseek-pro",
models: [
{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true },
{ id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false },
],
});
export const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
export const emptyProfile: Profile = {
name: "",
date: "",
time: "",
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
declaredWindowStart: "",
declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "",
rectificationCaseId: "",
countryCode: "CN",
provinceCode: "",
cityCode: "",
districtCode: "",
birthPlaceLabel: "",
birthPlaceType: "",
birthPlaceProvider: "",
birthPlaceProviderId: "",
timezoneId: "",
timezoneSource: "",
latitude: null,
longitude: null,
timezoneOffset: null,
};
export type StoredDailyStarlanguage = {
readonly day: string;
readonly fingerprint: string;
readonly card: DailyStarlanguageCard;
};
export const dailyStarlanguageRetryDelayMs = 5_000;
export function timestamp() {
return Date.now();
}
export const birthLocationKeys = [
"countryCode",
"provinceCode",
"cityCode",
"districtCode",
"birthPlaceLabel",
"birthPlaceProviderId",
"timezoneId",
"latitude",
"longitude",
"timezoneOffset",
] as const;
+1 -1
View File
@@ -9,9 +9,9 @@ import {
findProvinceNode,
resolveBirthPlaceNode,
} from "../src/lib/china-birth-place.ts";
import { homeSurface as pageSource } from "./home-surface.ts";
const pickerSource = readFileSync(new URL("../src/components/birth-place-picker.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
test("resolves a province, city and district to the district centre", () => {
const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "130402" });
@@ -15,6 +15,7 @@ import {
resolveRectificationCardAction,
unverifiedBirthTime,
} from "../src/lib/birth-time-consultation-consent.ts";
import { homeSurface } from "./home-surface.ts";
import type { BirthTimeDraft } from "../src/lib/birth-time-intake-model.ts";
const reportedExactTime = {
@@ -98,7 +99,7 @@ test("the current reported minute wins over an old candidate and never falls bac
assert.equal(unverifiedBirthTime(periodCandidate), null);
assert.equal(unverifiedBirthTime(missingReportedCandidate), null);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
assert.match(page, /persistedReportedTime \|\| \(source === "legacy_import" \? time : ""\)/);
});
@@ -202,7 +203,7 @@ test("account refresh identities reject an older response after a newer case req
});
test("unverified birth time no longer emits a modal or toast gate", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
assert.doesNotMatch(page, /<BirthTimeSoftNotice|setBirthTimeSoftNotice/);
assert.doesNotMatch(page, /role="alertdialog"[\s\S]{0,300}出生时间还没有完成校正/);
@@ -226,7 +227,7 @@ test("birth time intake starts with exact or uncertain choices and keeps rectifi
});
test("homepage and profile result copy use the source-aware consultation options", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/);
@@ -235,7 +236,7 @@ test("homepage and profile result copy use the source-aware consultation options
});
test("onboarding form steps hide the dead composer and keep source choices in a single column", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(page, /const onboardingFormActive = !profileComplete && onboardingStep !== "name"/);
@@ -8,10 +8,10 @@ import {
characterRemainingVisible,
remainingThreshold,
} from "../src/lib/character-remaining.ts";
import { homeSurface as page } from "./home-surface.ts";
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
@@ -1,8 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as source } from "./home-surface.ts";
const route = readFileSync(new URL("../src/app/api/chart-profiles/route.ts", import.meta.url), "utf8");
test("other chart saves do not require the owner's rectification state", () => {
@@ -8,7 +8,7 @@ import {
type ChatReplyPhase,
} from "../src/lib/chat-reply-announcement.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as pageSource } from "./home-surface.ts";
const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.ts", import.meta.url), "utf8");
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as page } from "./home-surface.ts";
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
+1 -1
View File
@@ -14,7 +14,7 @@ import {
writeSessionUrl,
} from "../src/lib/chat-session-url.ts";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as page } from "./home-surface.ts";
const login = readFileSync(new URL("../src/components/email-otp-login.tsx", import.meta.url), "utf8");
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
const sessionA = "11111111-1111-4111-8111-111111111111";
+3 -2
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
import { homeSurface } from "./home-surface.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const values = {
@@ -178,7 +179,7 @@ test("session writes reject oversized transcripts before they reach storage", ()
});
test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8");
@@ -217,7 +218,7 @@ test("session API owns create and update while answer UI keeps sync failures out
test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as pageSource } from "./home-surface.ts";
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8");
+19 -18
View File
@@ -9,6 +9,7 @@ import {
resolveConsultationQuestion,
shouldLoadGeneralDailyPanchanga,
} from "../src/lib/consultation-entrypoint.ts";
import { homeSurface } from "./home-surface.ts";
test("plain consultation questions remain user-authored", () => {
// Given: an ordinary question without a product entrypoint.
@@ -115,7 +116,7 @@ test("consultation entrypoints form a closed public request enum", () => {
});
test("browser source does not own private entrypoint prompts", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
assert.doesNotMatch(source, /function buildDailyStarlanguageQuestion/);
assert.doesNotMatch(source, /function buildBirthTimeRectificationQuestion/);
@@ -124,7 +125,7 @@ test("browser source does not own private entrypoint prompts", () => {
});
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
assert.match(source, /dailyStarlanguageQuestion/);
assert.match(source, /从今日问起/);
@@ -144,7 +145,7 @@ test("ordinary product drafts keep the public question and clear hidden routing
});
test("homepage birth-time card opens the V9 Agentic surface via the server case API", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, /function openRectificationCase/);
@@ -157,7 +158,7 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case
});
test("homepage mounts the Agentic surface without invoking retired rectification starters", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
@@ -167,7 +168,7 @@ test("homepage mounts the Agentic surface without invoking retired rectification
});
test("homepage opens through the server Case API and merges the returned session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const start = source.indexOf("async function openRectificationCase");
const end = source.indexOf("async function openRectificationFromHomepage", start);
const handler = source.slice(start, end);
@@ -185,7 +186,7 @@ test("homepage opens through the server Case API and merges the returned session
});
test("the page never creates the session shell locally; the server owns session creation", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openRectificationCase");
const end = page.indexOf("async function openRectificationFromHomepage", start);
@@ -197,7 +198,7 @@ test("the page never creates the session shell locally; the server owns session
});
test("rectification cards render only inside the active rectification session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
assert.match(source, /activeSession\?\.sessionType === "birth_time_rectification"/);
assert.match(source, /session_type:\s*session\.sessionType/);
@@ -206,7 +207,7 @@ test("rectification cards render only inside the active rectification session",
});
test("selecting a rectification session resumes it through the exact-session open API", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const selectSession = source.slice(
source.indexOf("function selectSession("),
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
@@ -220,7 +221,7 @@ test("selecting a rectification session resumes it through the exact-session ope
});
test("homepage creation and sidebar selection resolve through distinct server intents", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/);
@@ -232,7 +233,7 @@ test("homepage creation and sidebar selection resolve through distinct server in
});
test("an answered conversation offers no suggested follow-up questions", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
@@ -253,7 +254,7 @@ test("an answered conversation offers no suggested follow-up questions", () => {
});
test("rectify-first handoffs stay as Agent context", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
@@ -262,7 +263,7 @@ test("rectify-first handoffs stay as Agent context", () => {
});
test("ordinary consultation uses current birth data without a rectification notice", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const sendStart = source.indexOf("async function send(");
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
@@ -274,7 +275,7 @@ test("ordinary consultation uses current birth data without a rectification noti
});
test("rectification mutations report pending state while session-level return controls stay absent", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
@@ -283,7 +284,7 @@ test("rectification mutations report pending state while session-level return co
});
test("session changes contain no birth-time notice state", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const selectSession = source.slice(
source.indexOf("function selectSession("),
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
@@ -294,7 +295,7 @@ test("session changes contain no birth-time notice state", () => {
});
test("profile and place saves do not auto-start the retired assessment flow", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName"));
const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime"));
@@ -315,7 +316,7 @@ test("consult route expands an optional entrypoint for both Agent and tool input
});
test("homepage entrypoints use two whole-card native actions", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const wholeCardActions = source.match(/className="product-entrypoint-hitarea"/g) ?? [];
assert.equal(wholeCardActions.length, 2);
@@ -345,7 +346,7 @@ test("starter cards use transient pressed feedback instead of sticky hover shadi
});
test("the starter heading is drawn per visit and the rectification card carries no fine print", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// Given: the heading reads from a variant seeded once per mount and redrawn when the home is left.
@@ -393,7 +394,7 @@ test("starter homepage keeps its editorial display typography", () => {
});
test("starter homepage stays editorial and hides technical chart parameters", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const start = source.indexOf('<div className="starter-list starter-workbench"');
const end = source.indexOf("{onboardingError &&", start);
+1 -2
View File
@@ -1,8 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as source } from "./home-surface.ts";
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
+3 -2
View File
@@ -12,6 +12,7 @@ import {
evidenceFromDailyGuidance,
parseDailyStarlanguageText,
} from "../src/lib/daily-starlanguage.ts";
import { homeSurface } from "./home-surface.ts";
const today = "2026-08-17";
@@ -262,7 +263,7 @@ test("profile fingerprints ignore object identity and name-only edits", () => {
test("the homepage card is engine-backed first, with Agent polish off the request path", () => {
const route = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const agents = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const generateCard = route.slice(route.indexOf("async function generateCard"), route.indexOf("function unavailable"));
const polish = route.slice(route.indexOf("function scheduleAgentPolish"), route.indexOf("async function generateCard"));
@@ -306,7 +307,7 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
});
test("the home requests the card exactly when it renders one, and retries a failed day once", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const page = homeSurface;
const effect = page.slice(
page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"),
+17
View File
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
const homeSurfaceFiles = [
"../src/app/page.tsx",
"../src/lib/home-types.ts",
"../src/components/onboarding-chat-message.tsx",
"../src/lib/home-profile.ts",
"../src/lib/home-cloud-sync.ts",
"../src/components/birth-location-fields.tsx",
"../src/components/profile-fields.tsx",
"../src/components/chart-library-panel.tsx",
"../src/components/starter-home.tsx",
] as const;
export const homeSurface = homeSurfaceFiles
.map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8"))
.join("\n");
+1 -1
View File
@@ -3,11 +3,11 @@ import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { homeSurface as homePageSource } from "./home-surface.ts";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
const homePageSource = readProjectFile("src/app/page.tsx");
const onboardingPaywallSource = readProjectFile("src/components/onboarding-redeem-paywall.tsx");
const pageSource = readProjectFile("src/app/membership/page.tsx");
const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx");
@@ -9,6 +9,7 @@ import {
onboardingProfileFingerprint,
onboardingRequestIdentity,
} from "../src/lib/onboarding-client.ts";
import { homeSurface } from "./home-surface.ts";
const completeProfile = {
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30",
@@ -60,7 +61,7 @@ test("the starter hero splits one time-aware greeting instead of drawing from a
});
test("the starter hero stays at a salutation and a question", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const source = homeSurface;
// Given: the hero heading is the question half of the time-aware greeting.
assert.match(source, /<p className="starter-greeting">\{starterGreeting\.salutation\}<\/p>/);
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
@@ -22,7 +23,6 @@ const route = readFileSync(
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const agent = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
+2 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as page } from "./home-surface.ts";
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
@@ -32,7 +32,7 @@ test("personal profile is limited to account basics and links to chart settings"
});
test("the chart library owns self-profile editing and keeps other-chart save separate", () => {
const charts = between(page, " renderChartLibrary() {", " renderGeneral() {");
const charts = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8");
assert.match(charts, /编辑本人资料/);
assert.match(charts, /onSubmit=\{saveProfile\}/);
assert.match(charts, /onSubmit=\{saveOtherChart\}/);
+1 -1
View File
@@ -10,7 +10,7 @@ import {
generalGuidedJyotishTopics,
} from "../src/lib/guided-jyotish-topics.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
import { homeSurface as pageSource } from "./home-surface.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8");
+2
View File
@@ -63,6 +63,8 @@ CORE_PYTEST_TARGETS = [
# full pytest tree but are workflow_dispatch only, so a stale window_scan
# assertion in this glob stayed red on origin/staging until listed here.
"tests/test_rectification_*.py",
# This file regexes frontend source. Home-split and other page.tsx moves must keep it green.
"tests/test_supabase_user_data_contract.py",
]
RUNTIME_TRUTH_PYTEST_TARGETS = [
+7 -1
View File
@@ -127,7 +127,13 @@ def test_rectification_cases_are_owner_scoped_and_auditable() -> None:
def test_web_onboarding_uses_the_deterministic_free_journey() -> None:
page = (FRONTEND / "src" / "app" / "page.tsx").read_text(encoding="utf-8")
page = "".join(
path.read_text(encoding="utf-8")
for path in (
FRONTEND / "src" / "app" / "page.tsx",
FRONTEND / "src" / "lib" / "home-profile.ts",
)
)
route = (
FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts"
).read_text(encoding="utf-8")
@@ -2,12 +2,14 @@ from pathlib import Path
PAGE = Path("frontend/src/app/page.tsx")
STARTER_HOME = Path("frontend/src/components/starter-home.tsx")
HOME_CLOUD = Path("frontend/src/lib/home-cloud-sync.ts")
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")
source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8")
assert "今日星语" in source
assert "fetchDailyStarlanguage" in source
assert "daily-starlanguage-card" in source
@@ -30,7 +32,7 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
def test_birth_time_rectification_entrypoint_is_productized() -> None:
source = PAGE.read_text(encoding="utf-8")
source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8")
assert "生时校正" in source
assert "birth-rectification-card" in source
assert "openRectificationFromHomepage" in source
+54 -15
View File
@@ -38,6 +38,24 @@ SYNASTRY_REPORT_MIGRATION = (
/ "20260718101000_repair_missing_synastry_reports.sql"
)
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
_FRONTEND_SRC = Path(__file__).resolve().parents[1] / "frontend" / "src"
HOME_SURFACE_FILES = (
PAGE,
_FRONTEND_SRC / "lib" / "home-types.ts",
_FRONTEND_SRC / "lib" / "home-profile.ts",
_FRONTEND_SRC / "lib" / "home-cloud-sync.ts",
_FRONTEND_SRC / "components" / "birth-location-fields.tsx",
_FRONTEND_SRC / "components" / "profile-fields.tsx",
_FRONTEND_SRC / "components" / "onboarding-chat-message.tsx",
_FRONTEND_SRC / "components" / "chart-library-panel.tsx",
_FRONTEND_SRC / "components" / "starter-home.tsx",
)
def _home_surface() -> str:
return "".join(path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES)
SESSION_CREATE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "route.ts"
SESSION_ITEM_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "[id]" / "route.ts"
ACCOUNT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "account" / "route.ts"
@@ -94,12 +112,16 @@ def test_user_profile_and_chat_session_database_contract() -> None:
def test_chat_page_uses_authenticated_cloud_persistence() -> None:
source = PAGE.read_text(encoding="utf-8")
source = _home_surface()
create_route = SESSION_CREATE_ROUTE.read_text(encoding="utf-8")
item_route = SESSION_ITEM_ROUTE.read_text(encoding="utf-8")
assert '.from("profiles")' in source
assert '.from("chat_sessions")' in source
# Former value: page talked to Supabase with `.from("profiles")` / `.from("chat_sessions")`.
# Persistence is now same-origin APIs; the lock is still "no browser-owned writes".
assert 'fetch("/api/account"' in source
assert 'fetch("/api/sessions"' in source
assert '.from("profiles")' not in source
assert '.from("chat_sessions")' not in source
assert 'await writeChatSession(session.id, values, mode)' in source
assert 'mode === "create" ? "/api/sessions"' in (
Path(__file__).resolve().parents[1]
@@ -114,16 +136,22 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None:
assert 'await persistSession(userSession)' not in source
assert source.index('updateSession(sessionId, () => userSession)') < source.index('await persistSession(completedSession)')
assert 'function completedOnboardingTranscript(profile: Profile, greeting: string): Message[]' in source
assert 'messages: [...preservedMessages, { role: "user", text: question }]' in source
# Former value: `messages: [...preservedMessages, { role: "user", text: question }]`
# The user turn is still appended unless the question is already present.
assert 'messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }]' in source
assert 'await persistSession(completedSession)' in source
assert 'const stoppedRequestAwaitingSettlement = useRef<string | null>(null)' in source
assert 'const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>())' in source
assert 'if (ownsInterface && !partialReply)' in source
assert 'await persistSession(interruptedSession)' in source
assert 'await persistence' in source
# Former values: persist interruptedSession when `ownsInterface && !partialReply`.
# Interrupted answers stay in page memory; the client does not replace the server transcript.
assert "await persistSession(interruptedSession)" not in source
assert "if (!cancelled && ownsInterface && pendingConsultation.current)" in source
assert "await persistence" in source
assert "pendingSessionId || cancellationInFlight.current" in source
assert "setCancellationPending(true)" in source
assert "系统正在以账户记录为准同步点数" in source
# Former value: "系统正在以账户记录为准同步点数"
assert "正在停止回答并申请退回本次点数…" in source
assert "void refreshAccount()" in source
assert "回答中途断开,已保留现有内容,本次已计费。" not in source
assert "本次已开始生成并计费" not in source
# Former value: `localStorage.setItem(chartLibraryStorageKey(accountId)`
@@ -134,7 +162,17 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None:
def test_account_profile_patch_rejects_array_payloads() -> None:
route = ACCOUNT_ROUTE.read_text(encoding="utf-8")
assert 'typeof payload !== "object" || Array.isArray(payload)' in route
schema = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "src"
/ "lib"
/ "account-profile-patch.ts"
).read_text(encoding="utf-8")
# Former value: `typeof payload !== "object" || Array.isArray(payload)` in the route.
# Arrays are still rejected: the route parses with a Zod object schema.
assert "accountProfilePatchSchema.safeParse" in route
assert "export const accountProfilePatchSchema = z.object({" in schema
assert "账户资料格式不正确" in route
@@ -142,7 +180,7 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None:
sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip()
route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8")
delete_route = CHART_PROFILE_DELETE_ROUTE.read_text(encoding="utf-8")
page = PAGE.read_text(encoding="utf-8")
page = _home_surface()
for token in (
"create table if not exists public.chart_profiles",
@@ -190,7 +228,7 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None:
"jyotisha_chart_library",
"保存失败,请重试",
"星盘库",
"添加其他星盘",
"添加其他人的星盘",
"用于合盘",
"设为默认",
):
@@ -208,8 +246,8 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
route = SYNASTRY_ROUTE.read_text(encoding="utf-8")
for token in (
'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"',
'postPython("/api/chart", birthPayload(body.selfProfile))',
'postPython("/api/chart", birthPayload(body.partnerProfile))',
'postPython("/api/chart", selfPayload)',
'postPython("/api/chart", partnerPayload)',
'postPython("/api/varga_full"',
'postPython("/api/synastry"',
"moonLongitude(selfChart)",
@@ -221,6 +259,7 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
"ashtakoot_plus_moon_nakshatra_d9",
'evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"]',
'status: "blocked"',
"synastryBirthPayload",
):
assert token in route
@@ -228,7 +267,7 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
def test_synastry_reports_are_cloud_persisted_per_user() -> None:
sql = re.sub(r"\s+", " ", SYNASTRY_REPORT_MIGRATION.read_text(encoding="utf-8").lower()).strip()
route = SYNASTRY_REPORT_ROUTE.read_text(encoding="utf-8")
page = PAGE.read_text(encoding="utf-8")
page = _home_surface()
for token in (
"create table if not exists public.synastry_reports",
@@ -290,7 +329,7 @@ def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None:
def test_profile_coordinates_are_persisted_with_database_bounds() -> None:
sql = re.sub(r"\s+", " ", COORDS_MIGRATION.read_text(encoding="utf-8").lower()).strip()
source = PAGE.read_text(encoding="utf-8")
source = _home_surface()
for definition in (
"latitude double precision",