feat: complete birth time consultation handoff
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# Private Consultation Entrypoints Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Keep homepage entrypoint copy short and public while expanding the actual consultation instructions only on the server, and make each entry card one responsive click target.
|
||||
|
||||
**Architecture:** The client sends an optional closed `entrypoint` enum beside the visible `question`. A server-only resolver expands trusted entrypoints before constructing Agent and calculation-tool input; persisted history continues to use the public question. Existing card articles gain a stretched native button rather than nested action buttons.
|
||||
|
||||
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Zod, Node test runner, token-driven CSS.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Public composer/history copy is exactly `深入看今日`, `生时校正`, or `再次校正`.
|
||||
- Internal entrypoint instructions must not remain in `frontend/src/app/page.tsx` or the browser bundle.
|
||||
- Ordinary typed questions, billing, undo, streaming, and persistence behavior must not change.
|
||||
- Do not assert natural-language prompt prose in tests; assert routing and data ownership.
|
||||
- Use existing `DESIGN.md` tokens and 44px keyboard-accessible card controls.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Server-owned entrypoint resolver
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/consultation-entrypoint.ts`
|
||||
- Create: `frontend/tests/consultation-entrypoint.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `consultationEntrypointSchema` and `resolveConsultationQuestion({ entrypoint, visibleQuestion, name, currentDate })`.
|
||||
- Returns: `{ kind: "plain" | "expanded"; modelQuestion: string }`.
|
||||
|
||||
- [x] **Step 1: Write the failing routing tests**
|
||||
|
||||
```ts
|
||||
assert.equal(resolveConsultationQuestion({ entrypoint: undefined, visibleQuestion: "普通问题", name: "林遥", currentDate: "2026-07-19" }).kind, "plain");
|
||||
const daily = resolveConsultationQuestion({ entrypoint: "daily_starlanguage", visibleQuestion: "深入看今日", name: "林遥", currentDate: "2026-07-19" });
|
||||
assert.equal(daily.kind, "expanded");
|
||||
assert.notEqual(daily.modelQuestion, "深入看今日");
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run the focused test and confirm RED**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/consultation-entrypoint.test.ts`
|
||||
|
||||
Expected: FAIL because the resolver module does not exist.
|
||||
|
||||
- [x] **Step 3: Implement the strict enum and resolver**
|
||||
|
||||
Use a Zod enum for `daily_starlanguage` and `birth_time_rectification`. Keep template strings in this server-imported module and return the visible question unchanged only for the plain branch.
|
||||
|
||||
- [x] **Step 4: Run the focused test and confirm GREEN**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/consultation-entrypoint.test.ts`
|
||||
|
||||
Expected: all entrypoint routing tests pass.
|
||||
|
||||
### Task 2: Consultation API and client state
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/api/consult/route.ts`
|
||||
- Modify: `frontend/src/app/page.tsx`
|
||||
- Modify: `frontend/tests/consultation-entrypoint.test.ts`
|
||||
- Modify: `frontend/tests/sidebar-contract.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: optional `entrypoint` enum and `resolveConsultationQuestion`.
|
||||
- Produces: Agent/tool requests using `modelQuestion`, while `userSession.messages` keeps `question`.
|
||||
|
||||
- [x] **Step 1: Add failing API/client ownership tests**
|
||||
|
||||
Assert structurally that the route schema includes the optional enum, tool input overrides `question` with `modelQuestion`, and the page contains no `buildDailyStarlanguageQuestion` or `buildBirthTimeRectificationQuestion` functions.
|
||||
|
||||
- [x] **Step 2: Run the focused tests and confirm RED**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/consultation-entrypoint.test.ts tests/sidebar-contract.test.ts`
|
||||
|
||||
- [x] **Step 3: Wire the server expansion**
|
||||
|
||||
Resolve once after validation. Use the expanded question for `consultationInputSchema.parse({ ...parsed.data, question: resolved.modelQuestion })` and the final Agent user content. Keep prompt-extraction checks and history based on user-controlled visible text.
|
||||
|
||||
- [x] **Step 4: Wire the client entrypoint state**
|
||||
|
||||
Add `draftEntrypoint` to composer state and `PendingConsultation`. Card selection sets it; textarea edits clear it; sending includes it and clears it; undo restores it. Keep optimistic/persisted `Message.text` equal to the visible composer question.
|
||||
|
||||
- [x] **Step 5: Run the focused tests and confirm GREEN**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/consultation-entrypoint.test.ts tests/sidebar-contract.test.ts`
|
||||
|
||||
### Task 3: Whole-card interaction
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/page.tsx`
|
||||
- Modify: `frontend/src/app/globals.css`
|
||||
- Modify: `frontend/DESIGN.md` only if the existing entrypoint-card contract cannot express the stretched action.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: card selection helpers from Task 2.
|
||||
- Produces: two semantic articles, each with one absolute inset native button and one lower-right visual action label.
|
||||
|
||||
- [x] **Step 1: Add a failing source contract**
|
||||
|
||||
Assert that both card articles contain a dedicated whole-card action and no nested visible action button inside `.daily-starlanguage-heading`.
|
||||
|
||||
- [x] **Step 2: Run the source contract and confirm RED**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/sidebar-contract.test.ts`
|
||||
|
||||
- [x] **Step 3: Implement whole-card markup and token-driven states**
|
||||
|
||||
Keep `article`, `dl`, and explanatory copy. Add a stretched button with an accessible name; render the visible action text in the lower-right. Add hover, active, focus-visible, disabled, and reduced-motion states using existing tokens.
|
||||
|
||||
- [x] **Step 4: Run the source contract and confirm GREEN**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/sidebar-contract.test.ts`
|
||||
|
||||
### Task 4: Regression and real-browser QA
|
||||
|
||||
**Files:**
|
||||
- Modify only files required by observed regressions.
|
||||
|
||||
- [x] **Step 1: Run the full frontend suite**
|
||||
|
||||
Run: `/opt/homebrew/bin/node --test tests/*.test.ts`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [x] **Step 2: Run lint and production build**
|
||||
|
||||
Run: `/opt/homebrew/bin/node node_modules/eslint/bin/eslint.js .`
|
||||
|
||||
Run: `/opt/homebrew/bin/node node_modules/next/dist/bin/next build --webpack`
|
||||
|
||||
Expected: zero lint errors and a successful production build.
|
||||
|
||||
- [x] **Step 3: Browser QA at desktop and 390px**
|
||||
|
||||
Use `?preview=birth-time-candidate-complete`. Click each full card and verify the composer contains only the short public text. Edit the composer and verify the request becomes ordinary. Verify focus ring, disabled behavior, CJK wrapping, no large middle button, and profile result visibility.
|
||||
|
||||
- [x] **Step 4: Verify request ownership**
|
||||
|
||||
Inspect the browser request body: it may contain only the public `question` plus the enum. Confirm the internal template is absent from page source, DOM, transcript, and persisted message objects.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Private Consultation Entrypoints Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make the “今日星语” and “生时校正” cards behave like concise product entrypoints without exposing internal consultation instructions in the composer, transcript, browser bundle, or persisted chat history.
|
||||
|
||||
## Approved interaction
|
||||
|
||||
- Clicking anywhere on the 今日星语 card places `深入看今日` in the composer.
|
||||
- Clicking the 生时校正 card places `生时校正` before a result exists and `再次校正` after a candidate or confirmed time exists.
|
||||
- Each card remains a semantic `article`; one stretched native button covers the card, and a quiet action label with an arrow sits at the lower-right edge.
|
||||
- The cards contain no nested visible button, so mobile layouts do not create a large control in the middle of the content.
|
||||
- Keyboard focus, disabled state, and the 44px interaction requirement apply to the whole-card button.
|
||||
|
||||
## Request contract
|
||||
|
||||
The browser may send one optional, closed entrypoint value:
|
||||
|
||||
```ts
|
||||
type ConsultationEntrypoint =
|
||||
| "daily_starlanguage"
|
||||
| "birth_time_rectification";
|
||||
```
|
||||
|
||||
The visible `question` remains the exact short sentence displayed to the user. The server validates the optional entrypoint and expands it into the internal model question. Arbitrary template names or client-authored prompt content are rejected by schema validation.
|
||||
|
||||
Normal typed questions omit `entrypoint` and retain their current behavior.
|
||||
|
||||
## Composer and transcript behavior
|
||||
|
||||
- Card selection stores the short visible question plus an internal entrypoint enum in React state.
|
||||
- Any manual edit to the composer clears the entrypoint enum, so edited text is treated as an ordinary user question.
|
||||
- Sending clears both fields.
|
||||
- Undo/cancel restores both fields, preserving the same behavior when the user retries.
|
||||
- Optimistic messages, persisted chat sessions, sharing, and future conversation history store only the visible short question.
|
||||
|
||||
## Server behavior
|
||||
|
||||
- A server-only resolver owns both internal prompt templates.
|
||||
- `daily_starlanguage` expands with authoritative server time, the validated birth/chart input, the requested daily sections, and the existing non-deterministic boundary.
|
||||
- `birth_time_rectification` expands with the validated birth/chart input, a request to continue or restart evidence-led rectification, and the existing rule that a candidate is not a proven birth minute.
|
||||
- The expanded question is used both in the final Agent message and in `consultationInputSchema` passed to the calculation tool.
|
||||
- User-visible history remains unchanged and never receives the expanded text.
|
||||
- Prompt-extraction safety continues to inspect user-controlled visible content; the trusted server expansion is not treated as user input.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
- Unknown entrypoint values return the existing 400 invalid-request response before billing.
|
||||
- A valid entrypoint with an edited visible sentence is impossible through the product UI because editing clears the enum; the server still treats the enum as authoritative if a direct API client supplies both.
|
||||
- Billing, cancellation settlement, streaming, model selection, and ordinary questions retain their current paths.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests prove each entrypoint selects a server expansion without pinning natural-language prompt prose.
|
||||
- Route contract tests prove the enum is optional, invalid values fail, and the expanded question reaches both Agent and tool input.
|
||||
- Client tests prove the browser source no longer contains the internal daily or rectification prompt builders, manual editing clears intent, and cancellation restores it.
|
||||
- Browser QA verifies whole-card click, short composer text, keyboard focus, and desktop/390px mobile layout.
|
||||
@@ -160,6 +160,14 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
|
||||
- **States:** default, hover, active, focus, disabled, loading, fallback notice.
|
||||
- **Visibility:** the three initial cards remain visible while the user types or chooses a question. They leave only after the question is submitted and the session receives its first user message.
|
||||
|
||||
### Product entrypoint card
|
||||
|
||||
- **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy; a compact action label and arrow sit at the lower right.
|
||||
- **Copy:** the composer and chat history show only the public labels “深入看今日”, “生时校正”, or “再次校正”. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server.
|
||||
- **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback.
|
||||
- **Responsive:** cards stack below 768px without introducing a large nested button; the footer keeps supporting copy flexible and the action label on one line.
|
||||
- **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target.
|
||||
|
||||
### Start greeting
|
||||
|
||||
- **Content:** invite the user to ask what matters now; do not repeat that birth data is ready or explain setup state.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { candidateWorkingTime } from "@/lib/birth-time-candidate-completion";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const requestSchema = z.object({
|
||||
caseId: z.string().uuid(),
|
||||
resultId: z.string().uuid(),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: stored, error: caseError } = await admin
|
||||
.from("birth_time_rectification_cases")
|
||||
.select("id,user_id,status,candidate_result_id,candidate_result,turn_state")
|
||||
.eq("id", parsed.data.caseId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
const time = candidateWorkingTime(stored, parsed.data);
|
||||
if (caseError || !time) {
|
||||
return NextResponse.json(
|
||||
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
active_birth_time: time,
|
||||
birth_time_status: "candidate",
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.eq("rectification_case_id", parsed.data.caseId)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
if (profileError || !profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "候选时间暂时无法保存", message: "当前评估结果仍已保留,请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, activeTime: time, birthTimeStatus: "candidate" });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "候选时间暂时无法保存" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/lib/birth-time-guide-service";
|
||||
import { BirthTimeJourneyActionError, createJourneyTurnActions } from "@/lib/birth-time-journey-actions";
|
||||
import { BirthTimeDynamicActionError } from "@/lib/birth-time-dynamic-actions";
|
||||
import { BirthTimeJourneyEngineError, createJyotishBirthTimeJourneyEngine } from "@/lib/birth-time-journey-engine";
|
||||
import { BirthTimeJourneyEngineConfigurationError, BirthTimeJourneyEngineError, createJyotishBirthTimeJourneyEngine } from "@/lib/birth-time-journey-engine";
|
||||
import { createBirthTimeJourneyService } from "@/lib/birth-time-journey-service";
|
||||
import {
|
||||
BirthTimeJourneyStoreError,
|
||||
@@ -166,6 +166,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (error instanceof BirthTimeJourneyStoreError
|
||||
|| error instanceof BirthTimeJourneyEngineError
|
||||
|| error instanceof BirthTimeJourneyEngineConfigurationError
|
||||
|| (error instanceof BirthTimeDynamicActionError && error.reason === "unavailable")) {
|
||||
return NextResponse.json(
|
||||
{ error: "生时引导暂时不可用", message: "当前资料已保留,请稍后重试。" },
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
resolveLanguageModel,
|
||||
} from "@/mastra/model";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import {
|
||||
consultationEntrypointSchema,
|
||||
resolveConsultationQuestion,
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing";
|
||||
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
@@ -21,6 +25,7 @@ export const maxDuration = 60;
|
||||
const chatRequestSchema = consultationInputSchema.extend({
|
||||
requestId: z.string().uuid(),
|
||||
modelId: z.string().trim().min(1).max(64),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
name: z.string().trim().max(80).optional().default(""),
|
||||
history: z.array(z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
@@ -36,6 +41,10 @@ function currentTimeContext(now = new Date()) {
|
||||
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
|
||||
}
|
||||
|
||||
function chinaCalendarDate(now: Date) {
|
||||
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function recordModelUsage(
|
||||
accounting: ReturnType<typeof createAdminSupabaseClient>,
|
||||
userId: string,
|
||||
@@ -105,6 +114,13 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const requestTime = new Date();
|
||||
const resolvedQuestion = resolveConsultationQuestion({
|
||||
visibleQuestion: parsed.data.question,
|
||||
entrypoint: parsed.data.entrypoint,
|
||||
currentDate: chinaCalendarDate(requestTime),
|
||||
});
|
||||
|
||||
const userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
let modelSelection;
|
||||
@@ -166,7 +182,10 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const { history, name } = parsed.data;
|
||||
const toolInput = consultationInputSchema.parse(parsed.data);
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...parsed.data,
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
});
|
||||
|
||||
const result = await getJyotishAgent(selectedModel).stream([
|
||||
...history.map((message) => message.role === "user"
|
||||
@@ -175,9 +194,9 @@ export async function POST(request: Request) {
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
currentTimeContext(),
|
||||
currentTimeContext(requestTime),
|
||||
name ? `用户称呼:${name}` : "",
|
||||
parsed.data.question,
|
||||
resolvedQuestion.modelQuestion,
|
||||
"\n需要查询星盘时,使用以下经过服务端校验的工具参数:",
|
||||
JSON.stringify(toolInput),
|
||||
].filter(Boolean).join("\n"),
|
||||
|
||||
@@ -45,7 +45,9 @@ function hasCompleteBirthProfile(profile: Record<string, unknown>) {
|
||||
profile.name
|
||||
&& profile.birth_date
|
||||
&& (profile.active_birth_time || profile.birth_time)
|
||||
&& (profile.birth_time_status === "confirmed" || (!profile.birth_time_status && profile.birth_time))
|
||||
&& (profile.birth_time_status === "confirmed"
|
||||
|| profile.birth_time_status === "candidate"
|
||||
|| (!profile.birth_time_status && profile.birth_time))
|
||||
&& profile.country_code
|
||||
&& profile.province_code
|
||||
&& profile.city_code,
|
||||
|
||||
@@ -131,6 +131,12 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.onboarding-card-heading { display: grid; gap: 4px; padding-bottom: 2px; }
|
||||
.onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
|
||||
.onboarding-card-actions .button-primary { min-width: 84px; }
|
||||
.birth-time-transition-card { position: relative; overflow: hidden; }
|
||||
.birth-time-transition-fields { min-width: 0; display: grid; gap: 14px; margin: 0; padding: 0; border: 0; }
|
||||
.birth-time-assessment-overlay { position: absolute; z-index: 2; inset: 0; display: grid; place-items: center; padding: var(--space-6); border-radius: inherit; background: color-mix(in srgb, var(--color-canvas-soft) 94%, transparent); backdrop-filter: blur(2px); }
|
||||
.birth-time-assessment-progress { width: min(360px, 100%); display: grid; justify-items: center; gap: var(--space-2); color: var(--color-ink-secondary); text-align: center; }
|
||||
.birth-time-assessment-progress strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
|
||||
.birth-time-assessment-progress > span { font-size: var(--type-body-sm); line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; }
|
||||
.onboarding-inline-error { margin-left: 0; }
|
||||
.welcome > .starter-list { margin-left: 0; }
|
||||
.message-assistant { align-items: flex-start; justify-content: flex-start; gap: var(--space-3); }
|
||||
@@ -179,7 +185,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.status-已过期, .status-已兑换 { color: var(--color-ink-secondary); }
|
||||
.empty-cell { color: var(--color-ink-secondary); text-align: center !important; }
|
||||
|
||||
.new-chat:not(:disabled):active, .session-list button:not(:disabled):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not(:disabled):active, .starter-list button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.98); }
|
||||
.new-chat:not(:disabled):active, .session-list button:not(:disabled):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not(:disabled):active, .starter-list > button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.98); }
|
||||
|
||||
@keyframes app-loading-orbit { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { from { opacity: .28; transform: translateY(1px); } to { opacity: 1; transform: translateY(-1px); } }
|
||||
@@ -209,7 +215,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-header > div { min-width: 0; flex: 1; }
|
||||
.chat-header strong { max-width: 100%; }
|
||||
.onboarding-card { padding: 16px; }
|
||||
.starter-list button { min-height: 64px; }
|
||||
.starter-list > button { min-height: 64px; }
|
||||
.starter-content span { font-size: 13px; }
|
||||
.message-assistant .message-content { max-width: 100%; }
|
||||
.message p, .message-markdown { font-size: 16px; }
|
||||
@@ -340,6 +346,15 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
|
||||
.onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); }
|
||||
.birth-time-intake { display: grid; gap: var(--space-4); }
|
||||
.birth-time-profile-result { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--color-success) 32%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-success) 5%, var(--color-canvas)); }
|
||||
.birth-time-profile-result-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.birth-time-profile-result-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; }
|
||||
.birth-time-profile-result-heading strong { color: var(--color-success); font-size: var(--type-caption); }
|
||||
.birth-time-profile-result dl { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-border); }
|
||||
.birth-time-profile-result dl > div { display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas); }
|
||||
.birth-time-profile-result dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.birth-time-profile-result dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; line-height: 1.45; }
|
||||
.birth-time-profile-result p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; }
|
||||
.birth-time-source-fieldset { margin: 0; padding: 0; border: 0; }
|
||||
.birth-time-source-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
|
||||
.birth-time-source-list { display: grid; gap: var(--space-2); }
|
||||
@@ -418,26 +433,35 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.phrase-nowrap { white-space: nowrap; }
|
||||
|
||||
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; }
|
||||
.starter-list button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 136px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.starter-list button:first-child { border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); }
|
||||
.starter-list button:first-child .starter-content span { color: var(--color-ink); white-space: normal; }
|
||||
.starter-list button:first-child .starter-arrow { color: var(--color-action); }
|
||||
.starter-list > button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 136px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.starter-list > button:first-of-type { border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); }
|
||||
.starter-list > button:first-of-type .starter-content span { color: var(--color-ink); white-space: normal; }
|
||||
.starter-list > button:first-of-type .starter-arrow { color: var(--color-action); }
|
||||
.starter-content { min-width: 0; display: grid; gap: var(--space-2); }
|
||||
.starter-content b { color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
|
||||
.starter-content span { overflow: hidden; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.4; text-overflow: clip; text-wrap: pretty; white-space: normal; }
|
||||
.starter-arrow { width: 17px; height: 17px; color: var(--color-ink-secondary); }
|
||||
.product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: var(--space-3); }
|
||||
.product-entrypoints button { min-height: 132px; align-items: end; border: 1px solid var(--color-border); background: var(--color-canvas); }
|
||||
.product-entrypoints span { display: grid; gap: var(--space-2); }
|
||||
.product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.product-entrypoint-card { position: relative; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
|
||||
.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
|
||||
.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(-1px); }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { transform: translateY(0); }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: .65; }
|
||||
.product-entrypoint-hitarea:disabled { cursor: not-allowed; }
|
||||
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; }
|
||||
.daily-starlanguage-heading button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; color: var(--color-ink); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; }
|
||||
.daily-starlanguage-card div, .birth-rectification-card div { display: grid; gap: 4px; }
|
||||
.daily-starlanguage-card dl > div, .birth-rectification-card dl > div { display: grid; gap: 4px; }
|
||||
.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
|
||||
.product-entrypoint-footer { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3); }
|
||||
.product-entrypoint-footer small { min-width: 0; flex: 1; }
|
||||
.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; }
|
||||
.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; }
|
||||
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
|
||||
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
|
||||
|
||||
@@ -606,8 +630,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
|
||||
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.starter-list button:not(:disabled):hover { background: var(--color-canvas-strong); }
|
||||
.starter-list button:first-child:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); }
|
||||
.starter-list > button:not(:disabled):hover { background: var(--color-canvas-strong); }
|
||||
.starter-list > button:first-of-type:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); }
|
||||
.composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
@@ -616,7 +640,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
|
||||
@media (min-width: 768px) and (max-width: 900px) {
|
||||
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
|
||||
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.starter-list > button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.product-entrypoints { grid-template-columns: 1fr; }
|
||||
.auth-shell { grid-template-columns: 1.2fr .8fr; }
|
||||
.auth-story, .auth-panel { padding: var(--space-8); }
|
||||
@@ -637,7 +661,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.welcome { padding: var(--space-6) 0 var(--space-10); }
|
||||
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-display-sm); }
|
||||
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
|
||||
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.starter-list > button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.product-entrypoints { grid-template-columns: 1fr; }
|
||||
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
|
||||
.message-content { max-width: 88%; }
|
||||
@@ -659,6 +683,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.birth-time-assessment-heading { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-2); }
|
||||
.birth-time-status-badge { justify-self: start; }
|
||||
.birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list, .birth-time-candidate-grid { grid-template-columns: 1fr; }
|
||||
.birth-time-profile-result dl { grid-template-columns: 1fr; }
|
||||
.birth-time-evidence-actions > button, .birth-time-guided-actions > button, .birth-time-candidate-terminal > button, .birth-time-confirmation-panel > button { width: 100%; }
|
||||
.birth-time-draft-fields { grid-template-columns: 1fr; }
|
||||
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); }
|
||||
|
||||
+143
-57
@@ -5,6 +5,10 @@ import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import {
|
||||
BirthTimeAssessmentOverlay,
|
||||
type BirthTimeAssessmentPhase,
|
||||
} from "@/components/birth-time-assessment-overlay";
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { BirthTimeRectification } from "@/components/birth-time-rectification";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
@@ -14,11 +18,14 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
|
||||
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
import {
|
||||
assistantIntentCopy,
|
||||
birthTimeDisplayState,
|
||||
birthTimePersistenceValues,
|
||||
describeBirthTimeDraft,
|
||||
isBirthTimeDraftReady,
|
||||
isBirthTimeReadyForConsultation,
|
||||
type BirthTimeDraft,
|
||||
type BirthTimeSource,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
@@ -117,6 +124,7 @@ type PendingConsultation = {
|
||||
readonly requestId: string;
|
||||
readonly sessionId: string;
|
||||
readonly question: string;
|
||||
readonly entrypoint: ConsultationEntrypoint | null;
|
||||
readonly theme: Theme;
|
||||
readonly previousSession: ChatSession;
|
||||
readonly optimisticSession: ChatSession;
|
||||
@@ -382,16 +390,6 @@ function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildDailyStarlanguageQuestion(profile: Profile) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return [
|
||||
`请生成今日星语:${today},对象是${profile.name || "我"}。`,
|
||||
`出生资料:${profile.date} ${profile.time},${profilePlaceLabel(profile)}。`,
|
||||
"请输出今日趋势、适合推进的事、需要避开的事、一个行动建议。",
|
||||
"边界:这是探索性日提示,不是确定预测;若涉及精确事件日期,请标为候选触发,不要包装成必然结论。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildDailyStarlanguageCard(profile: Profile) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const seed = `${today}-${profile.date}-${profile.time}-${profile.provinceCode}-${profile.cityCode}`;
|
||||
@@ -421,20 +419,11 @@ async function fetchBirthRectificationPreview(profile: Profile) {
|
||||
return await response.json().catch(() => null) as BirthRectificationPreview | null;
|
||||
}
|
||||
|
||||
function buildBirthTimeRectificationQuestion(profile: Profile) {
|
||||
return [
|
||||
`请为${profile.name || "我"}做生时校正辅助。`,
|
||||
`当前记录:${profile.date} ${profile.time || "时间不确定"},${profilePlaceLabel(profile)}。`,
|
||||
"请先列出需要我补充的关键人生事件,再给候选出生时间段、每段会影响的 Lagna/分盘/大运差异。",
|
||||
"边界:候选出生时间段必须标为待验证,不能直接改写默认星盘;没有事件证据前不要声称校正完成。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function missingProfileStep(profile: Profile): OnboardingStep | null {
|
||||
if (!profile.name.trim()) return "name";
|
||||
if (!isBirthTimeDraftReady(profile)) return "birth";
|
||||
if (!selectedBirthPlace(profile)) return "place";
|
||||
if (!profile.time || profile.birthTimeStatus !== "confirmed") return "rectification";
|
||||
if (!isBirthTimeReadyForConsultation(profile)) return "rectification";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -457,7 +446,7 @@ function completedOnboardingMessage(name: string) {
|
||||
function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] {
|
||||
const name = profile.name.trim();
|
||||
const birthPlace = selectedBirthPlace(profile);
|
||||
if (!name || !profile.date || !profile.time || profile.birthTimeStatus !== "confirmed" || !birthPlace) return [];
|
||||
if (!name || !profile.date || !isBirthTimeReadyForConsultation(profile) || !birthPlace) return [];
|
||||
|
||||
return [
|
||||
{ role: "assistant", text: presetOnboardingMessage },
|
||||
@@ -743,6 +732,7 @@ export default function Home() {
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
|
||||
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
|
||||
const [composerNotice, setComposerNotice] = useState("");
|
||||
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | null>(null);
|
||||
const [cancellationPending, setCancellationPending] = useState(false);
|
||||
@@ -758,6 +748,7 @@ export default function Home() {
|
||||
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
|
||||
const [birthTimeJourney, setBirthTimeJourney] = useState<JourneyClientResponse | null>(null);
|
||||
const [birthTimeError, setBirthTimeError] = useState("");
|
||||
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState<BirthTimeAssessmentPhase | null>(null);
|
||||
const [startGreeting, setStartGreeting] = useState("");
|
||||
const [presetMessageLength, setPresetMessageLength] = useState(0);
|
||||
const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
@@ -787,6 +778,7 @@ export default function Home() {
|
||||
preview: process.env.NODE_ENV === "development" && uiPreview.current,
|
||||
onJourney: setBirthTimeJourney,
|
||||
onReady: completeGuidedBirthTime,
|
||||
onCandidateComplete: completeCandidateBirthTime,
|
||||
onEditBirthTimeDetails: editDeclaredBirthTimeDetails,
|
||||
});
|
||||
|
||||
@@ -796,6 +788,7 @@ export default function Home() {
|
||||
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
|
||||
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
|
||||
const isLoading = pendingSessionId === activeSession?.id;
|
||||
const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog;
|
||||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||||
const accountId = account?.user.id;
|
||||
|
||||
@@ -882,6 +875,7 @@ export default function Home() {
|
||||
}, [accountId, profile]);
|
||||
|
||||
const profileComplete = isProfileComplete(profile);
|
||||
const birthTimeDisplay = birthTimeDisplayState(profile);
|
||||
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
|
||||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||||
const currentOnboardingMessage = onboardingJustCompleted
|
||||
@@ -895,6 +889,7 @@ export default function Home() {
|
||||
: presetOnboardingMessage;
|
||||
const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted;
|
||||
const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length;
|
||||
const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -918,6 +913,8 @@ export default function Home() {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading";
|
||||
const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete";
|
||||
const isRectificationPreview = isGuidedBirthTimePreview(previewMode);
|
||||
const previewJourney = isRectificationPreview
|
||||
? guidedBirthTimePreview(previewMode)
|
||||
@@ -927,14 +924,18 @@ export default function Home() {
|
||||
: {
|
||||
name: "林遥",
|
||||
date: "1990-06-15",
|
||||
time: isRectificationPreview ? "" : "12:30",
|
||||
reportedTime: isRectificationPreview ? "14:30" : "12:30",
|
||||
birthTimeSource: isRectificationPreview ? "approximate" : "legacy_import",
|
||||
birthTimePeriod: "",
|
||||
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
|
||||
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
|
||||
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
|
||||
birthTimePeriod: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
|
||||
birthTimeClue: "",
|
||||
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
|
||||
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
|
||||
birthTimeStatus: previewJourney.snapshot.state === "candidate"
|
||||
birthTimeStatus: isCompletedCandidatePreview
|
||||
? "candidate"
|
||||
: isAssessmentLoadingPreview
|
||||
? "rectifying"
|
||||
: previewJourney.snapshot.state === "candidate"
|
||||
|| previewJourney.snapshot.state === "confirming"
|
||||
|| previewJourney.snapshot.state === "ready"
|
||||
? "candidate"
|
||||
@@ -966,7 +967,10 @@ export default function Home() {
|
||||
if (isRectificationPreview) {
|
||||
setBirthTimeJourney(previewJourney);
|
||||
}
|
||||
setOnboardingStep(missingProfileStep(previewProfile) ?? "name");
|
||||
if (isAssessmentLoadingPreview) {
|
||||
setBirthTimeAssessmentPhase("assessing");
|
||||
}
|
||||
setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name");
|
||||
setSessions([previewSession]);
|
||||
setActiveSessionId(previewSession.id);
|
||||
const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : "";
|
||||
@@ -1045,7 +1049,8 @@ export default function Home() {
|
||||
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
|
||||
setSessions(nextSessions);
|
||||
setActiveSessionId(nextSessions[0].id);
|
||||
if ((nextProfile.birthTimeStatus === "rectifying" || nextProfile.birthTimeStatus === "candidate")
|
||||
if ((nextProfile.birthTimeStatus === "rectifying"
|
||||
|| (nextProfile.birthTimeStatus === "candidate" && !nextProfile.time))
|
||||
&& nextProfile.rectificationCaseId) {
|
||||
try {
|
||||
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
|
||||
@@ -1152,7 +1157,7 @@ export default function Home() {
|
||||
}, [accountId, hydrated, onboarding, onboardingError, profile.name, profileComplete, startGreeting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete) return;
|
||||
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
|
||||
let cancelled = false;
|
||||
setDailyStarlanguageCard(null);
|
||||
void fetchDailyStarlanguage(profile)
|
||||
@@ -1165,7 +1170,7 @@ export default function Home() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
}, [hydrated, profile.date, profile.time, profile.birthTimeStatus, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete) return;
|
||||
@@ -1331,6 +1336,7 @@ export default function Home() {
|
||||
setActiveSessionId(nextSession.id);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
try {
|
||||
@@ -1350,6 +1356,7 @@ export default function Home() {
|
||||
function selectSession(sessionId: string) {
|
||||
setActiveSessionId(sessionId);
|
||||
setDraft("");
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}
|
||||
|
||||
@@ -1592,24 +1599,28 @@ export default function Home() {
|
||||
event.preventDefault();
|
||||
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("saving_profile");
|
||||
setAccountError("");
|
||||
try {
|
||||
await persistProfile(profileDraft);
|
||||
setPresetMessageLength(0);
|
||||
if (birthTimeRevisionPending.current) {
|
||||
setBirthTimeAssessmentPhase("assessing");
|
||||
const assessedProfile = await assessSavedBirthTime(profileDraft);
|
||||
birthTimeRevisionPending.current = false;
|
||||
setPresetMessageLength(0);
|
||||
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true);
|
||||
else setOnboardingStep("rectification");
|
||||
return;
|
||||
}
|
||||
setProfile(profileDraft);
|
||||
setPresetMessageLength(0);
|
||||
const nextStep = missingProfileStep(profileDraft);
|
||||
if (nextStep) setOnboardingStep(nextStep);
|
||||
else setOnboardingJustCompleted(true);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
@@ -1625,9 +1636,11 @@ export default function Home() {
|
||||
event.preventDefault();
|
||||
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("saving_profile");
|
||||
setAccountError("");
|
||||
try {
|
||||
await persistProfile(profileDraft);
|
||||
setBirthTimeAssessmentPhase("assessing");
|
||||
const assessedProfile = await assessSavedBirthTime(profileDraft);
|
||||
setPresetMessageLength(0);
|
||||
if (assessedProfile.birthTimeStatus === "confirmed") {
|
||||
@@ -1638,6 +1651,7 @@ export default function Home() {
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
@@ -1656,6 +1670,21 @@ export default function Home() {
|
||||
setOnboardingJustCompleted(true);
|
||||
}
|
||||
|
||||
function completeCandidateBirthTime(result: JourneyClientResponse, time: string) {
|
||||
const candidateProfile: Profile = {
|
||||
...profileDraft,
|
||||
time,
|
||||
birthTimeStatus: "candidate",
|
||||
rectificationCaseId: result.caseId,
|
||||
};
|
||||
setProfile(candidateProfile);
|
||||
setProfileDraft(candidateProfile);
|
||||
setBirthTimeJourney(null);
|
||||
setPresetMessageLength(0);
|
||||
setStartGreeting(createStartGreeting(candidateProfile.name));
|
||||
setOnboardingJustCompleted(true);
|
||||
}
|
||||
|
||||
async function retryBirthTimeAssessment() {
|
||||
if (!account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
@@ -1716,20 +1745,29 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
function chooseSuggestedQuestion(question: string, theme?: Theme) {
|
||||
function chooseSuggestedQuestion(
|
||||
question: string,
|
||||
theme?: Theme,
|
||||
entrypoint: ConsultationEntrypoint | null = null,
|
||||
) {
|
||||
if (pendingSessionId || cancellationInFlight.current) return;
|
||||
setDraft(question);
|
||||
setDraftTheme(theme ?? null);
|
||||
setDraftEntrypoint(entrypoint);
|
||||
setComposerNotice("");
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
}
|
||||
|
||||
function draftDailyStarlanguageQuestion() {
|
||||
chooseSuggestedQuestion(buildDailyStarlanguageQuestion(profile), "timing");
|
||||
chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage");
|
||||
}
|
||||
|
||||
function draftBirthTimeRectificationQuestion() {
|
||||
chooseSuggestedQuestion(buildBirthTimeRectificationQuestion(profile), "timing");
|
||||
chooseSuggestedQuestion(
|
||||
birthTimeDisplay ? "再次校正" : "生时校正",
|
||||
"timing",
|
||||
"birth_time_rectification",
|
||||
);
|
||||
}
|
||||
|
||||
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) {
|
||||
@@ -1887,6 +1925,7 @@ export default function Home() {
|
||||
setOnboardingJustCompleted(pending.previousOnboardingState);
|
||||
setDraft(pending.question);
|
||||
setDraftTheme(pending.theme);
|
||||
setDraftEntrypoint(pending.entrypoint);
|
||||
setStreamingReply(null);
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
@@ -1910,7 +1949,11 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
async function send(text: string, requestedTheme?: Theme) {
|
||||
async function send(
|
||||
text: string,
|
||||
requestedTheme?: Theme,
|
||||
entrypoint: ConsultationEntrypoint | null = null,
|
||||
) {
|
||||
const originalQuestion = text;
|
||||
const question = text.trim();
|
||||
if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
|
||||
@@ -1957,6 +2000,7 @@ export default function Home() {
|
||||
requestId,
|
||||
sessionId,
|
||||
question: originalQuestion,
|
||||
entrypoint,
|
||||
theme,
|
||||
previousSession: currentSession,
|
||||
optimisticSession: userSession,
|
||||
@@ -1970,6 +2014,7 @@ export default function Home() {
|
||||
updateSession(sessionId, () => userSession);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) {
|
||||
setStreamingReply({ sessionId, text: "" });
|
||||
@@ -2031,6 +2076,7 @@ export default function Home() {
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
modelId: currentSession.modelId,
|
||||
entrypoint: entrypoint ?? undefined,
|
||||
name: profile.name,
|
||||
year,
|
||||
month,
|
||||
@@ -2110,6 +2156,7 @@ export default function Home() {
|
||||
if (activeSessionIdRef.current === sessionId) {
|
||||
setDraft(originalQuestion);
|
||||
setDraftTheme(theme);
|
||||
setDraftEntrypoint(entrypoint);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setRequestError({
|
||||
@@ -2178,7 +2225,7 @@ export default function Home() {
|
||||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||||
return;
|
||||
}
|
||||
void send(draft, draftTheme ?? undefined);
|
||||
void send(draft, draftTheme ?? undefined, draftEntrypoint);
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -2306,8 +2353,8 @@ export default function Home() {
|
||||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
|
||||
{onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe={onboardingStep === "rectification"} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
|
||||
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
|
||||
</>
|
||||
@@ -2318,27 +2365,33 @@ export default function Home() {
|
||||
: startGreeting || `${profile.name.trim()},从你此刻最关心的问题开始吧。`)} />
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "birth" && presetMessageFinished && (
|
||||
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingBirth}>
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生时间</b><small>按你实际知道的程度填写,不需要猜测</small></div>
|
||||
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => ({ ...current, ...patch }))} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => ({ ...current, ...patch }))} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "place" && presetMessageFinished && (
|
||||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card" onSubmit={saveOnboardingPlace}>
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>目前先支持中国大陆地区</small></div>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2371,29 +2424,61 @@ export default function Home() {
|
||||
) : (
|
||||
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
|
||||
<div className="product-entrypoints" aria-label="常用占星入口">
|
||||
<article className="daily-starlanguage-card" aria-label="今日星语">
|
||||
<article className="daily-starlanguage-card product-entrypoint-card" aria-label="今日星语">
|
||||
<button
|
||||
className="product-entrypoint-hitarea"
|
||||
type="button"
|
||||
aria-label="深入看今日"
|
||||
disabled={productEntrypointsDisabled}
|
||||
onClick={draftDailyStarlanguageQuestion}
|
||||
/>
|
||||
<div className="daily-starlanguage-heading">
|
||||
<span>今日星语</span>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftDailyStarlanguageQuestion}>深入看今日 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>今日趋势</dt><dd>{dailyStarlanguage?.trend}</dd></div>
|
||||
<div><dt>行动建议</dt><dd>{dailyStarlanguage?.action}</dd></div>
|
||||
<div><dt>今日提醒</dt><dd>{dailyStarlanguage?.caution}</dd></div>
|
||||
</dl>
|
||||
<small>探索性日提示,不是确定预测。</small>
|
||||
<div className="product-entrypoint-footer">
|
||||
<small>探索性日提示,不是确定预测。</small>
|
||||
<span className="product-entrypoint-action" aria-hidden="true">深入看今日 <ArrowUpRight className="starter-arrow" /></span>
|
||||
</div>
|
||||
</article>
|
||||
<article className="birth-rectification-card" aria-label="生时校正">
|
||||
<article className="birth-rectification-card product-entrypoint-card" aria-label="生时校正">
|
||||
<button
|
||||
className="product-entrypoint-hitarea"
|
||||
type="button"
|
||||
aria-label={birthTimeDisplay ? "再次校正" : "生时校正"}
|
||||
disabled={productEntrypointsDisabled}
|
||||
onClick={draftBirthTimeRectificationQuestion}
|
||||
/>
|
||||
<div className="daily-starlanguage-heading">
|
||||
<span>生时校正</span>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>开始校正 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>候选出生时间段</dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
|
||||
<div><dt>候选点</dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}</dd></div>
|
||||
<div><dt>问题数</dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
|
||||
{birthTimeDisplay ? (
|
||||
<>
|
||||
<div><dt>{birthTimeDisplay.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}</dt><dd>{birthTimeDisplay.activeTime}</dd></div>
|
||||
<div><dt>结果状态</dt><dd>{birthTimeDisplay.kind === "candidate" ? "候选时间(已用于排盘)" : "已确认"}</dd></div>
|
||||
<div><dt>原始填报</dt><dd>{birthTimeDisplay.reportedLabel}</dd></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><dt>候选出生时间段</dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
|
||||
<div><dt>候选点</dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}</dd></div>
|
||||
<div><dt>问题数</dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<small>不能直接改写默认星盘;需事件证据验证。</small>
|
||||
<div className="product-entrypoint-footer">
|
||||
<small>{birthTimeDisplay?.kind === "candidate"
|
||||
? "当前使用候选时间排盘;原始填报范围仍保留。"
|
||||
: birthTimeDisplay?.kind === "confirmed"
|
||||
? "当前排盘时间已经确认。"
|
||||
: "不能直接改写默认星盘;需事件证据验证。"}</small>
|
||||
<span className="product-entrypoint-action" aria-hidden="true">{birthTimeDisplay ? "再次校正" : "生时校正"} <ArrowUpRight className="starter-arrow" /></span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
|
||||
@@ -2467,6 +2552,7 @@ export default function Home() {
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export type BirthTimeAssessmentPhase = "saving_profile" | "assessing";
|
||||
|
||||
const progressCopy = {
|
||||
saving_profile: {
|
||||
title: "正在保存出生资料",
|
||||
detail: "已保留你刚才的选择,马上开始生成生时评估。",
|
||||
},
|
||||
assessing: {
|
||||
title: "正在生成生时评估",
|
||||
detail: "正在读取已有资料并准备下一步,不需要重复操作。",
|
||||
},
|
||||
} as const satisfies Record<BirthTimeAssessmentPhase, { readonly title: string; readonly detail: string }>;
|
||||
|
||||
export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) {
|
||||
if (phase === null) return null;
|
||||
const copy = progressCopy[phase];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="birth-time-assessment-overlay"
|
||||
data-phase={phase}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="birth-time-assessment-progress">
|
||||
<div className="app-loading-symbol" aria-hidden="true">
|
||||
<span className="app-loading-orbit" />
|
||||
<span className="app-loading-mark" />
|
||||
</div>
|
||||
<strong>{copy.title}</strong>
|
||||
<span>{copy.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<p className="birth-time-evidence-boundary">系统不会选择或应用未经证据支持的具体分钟,<span className="phrase-nowrap">当前排盘使用时间</span>保持不变。</p>
|
||||
{terminalPath && <NewAssessmentAction controller={controller} />}
|
||||
{terminalPath && <TerminalAction controller={controller} path={terminalPath} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,13 +52,13 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
{action.kind === "present_low_result" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p>{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}</p>
|
||||
{terminalPath && <NewAssessmentAction controller={controller} />}
|
||||
{terminalPath && <TerminalAction controller={controller} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "present_medium_result" && winner && dynamic && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p>已形成较窄的候选范围,本次评估已结束;它不会自动改动<span className="phrase-nowrap">当前排盘使用时间</span>。</p>
|
||||
<NewAssessmentAction controller={controller} />
|
||||
{terminalPath && <TerminalAction controller={controller} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "present_medium_result" && winner && !dynamic && (
|
||||
@@ -72,7 +72,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
{action.kind === "candidate_saved" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p className="birth-time-success-note">候选时间范围已保存。<span className="phrase-nowrap">当前排盘使用时间</span>没有改变。</p>
|
||||
{terminalPath && <NewAssessmentAction controller={controller} />}
|
||||
{terminalPath && <TerminalAction controller={controller} path={terminalPath} />}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "request_candidate_confirmation" && winner && (
|
||||
@@ -94,9 +94,20 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
);
|
||||
}
|
||||
|
||||
function NewAssessmentAction({ controller }: {
|
||||
function TerminalAction({ controller, path }: {
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
|
||||
}) {
|
||||
if (path.kind === "complete_with_candidate") {
|
||||
return (
|
||||
<div className="birth-time-new-assessment">
|
||||
<button className="button-primary birth-time-guided-action" disabled={controller.pending} onClick={() => controller.completeCandidate(path.time)} type="button">
|
||||
{controller.pending ? "保存中…" : "完成并继续对话"}
|
||||
</button>
|
||||
<small>将以 {path.time} 作为当前工作排盘时间;候选状态和原始资料都会保留。</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="birth-time-new-assessment">
|
||||
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button">开始新的评估</button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useId } from "react";
|
||||
import { BirthDatePicker } from "@/components/birth-date-picker";
|
||||
import {
|
||||
birthTimeDisplayState,
|
||||
birthTimePeriodOptions,
|
||||
birthTimeSourceOptions,
|
||||
type BirthTimeDraft,
|
||||
@@ -52,6 +53,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
const groupId = useId();
|
||||
const source = value.birthTimeSource;
|
||||
const isConfirmed = value.birthTimeStatus === "confirmed";
|
||||
const displayState = birthTimeDisplayState(value);
|
||||
const usesClockTime = source === "hospital_record"
|
||||
|| source === "family_exact"
|
||||
|| source === "approximate"
|
||||
@@ -59,6 +61,27 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
|
||||
|
||||
return (
|
||||
<div className="birth-time-intake">
|
||||
{displayState && (
|
||||
<section className="birth-time-profile-result" aria-label="生时校正结果">
|
||||
<div className="birth-time-profile-result-heading">
|
||||
<span>生时校正结果</span>
|
||||
<strong>{displayState.kind === "candidate" ? "候选时间" : "已确认"}</strong>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{displayState.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}</dt>
|
||||
<dd>{displayState.activeTime}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>原始填报</dt>
|
||||
<dd>{displayState.reportedLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{displayState.kind === "candidate" && (
|
||||
<p>已用于当前排盘,但仍保留为候选结果,不会标记成出生记录中的确定分钟。</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<BirthDatePicker
|
||||
value={value.date}
|
||||
disabled={isConfirmed}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/birth-time-journey-client";
|
||||
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
||||
import {
|
||||
completeGuidedBirthTimeCandidate,
|
||||
confirmGuidedBirthTimeCandidate,
|
||||
reviseBirthTimeEvidenceDraft,
|
||||
saveGuidedBirthTimeCandidate,
|
||||
@@ -35,6 +36,7 @@ type GuidedJourneyInput = {
|
||||
readonly preview: boolean;
|
||||
readonly onJourney: (journey: JourneyClientResponse) => void;
|
||||
readonly onReady: (journey: JourneyClientResponse) => void;
|
||||
readonly onCandidateComplete: (journey: JourneyClientResponse, time: string) => void;
|
||||
readonly onEditBirthTimeDetails: () => void;
|
||||
};
|
||||
|
||||
@@ -50,6 +52,7 @@ export type BirthTimeGuidedController = {
|
||||
readonly resume: () => void;
|
||||
readonly editBirthTimeDetails: () => void;
|
||||
readonly acknowledgeReady: () => void;
|
||||
readonly completeCandidate: (time: string) => void;
|
||||
readonly retryScoring: () => void;
|
||||
readonly saveCandidate: (resultId: string) => void;
|
||||
readonly confirmCandidate: (resultId: string, time: string) => void;
|
||||
@@ -66,7 +69,7 @@ function previewAction(turn: JourneyClientResponse, command: DynamicPreviewComma
|
||||
}
|
||||
|
||||
export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController {
|
||||
const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input;
|
||||
const { journey, onJourney, onReady, onCandidateComplete, onEditBirthTimeDetails, preview } = input;
|
||||
const latest = useRef(journey);
|
||||
const busy = useRef(false);
|
||||
const [actionRegistry] = useState(() => createStableActionIdentityRegistry());
|
||||
@@ -181,6 +184,26 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
|
||||
const acknowledgeReady = () => {
|
||||
if (journey?.nextAction.kind === "ready") onReady(journey);
|
||||
};
|
||||
const completeCandidate = (time: string) => {
|
||||
const turn = journey;
|
||||
const resultId = turn?.candidateResult?.resultId;
|
||||
const winner = turn?.candidateResult?.winningSegment;
|
||||
if (!turn || !resultId || winner?.representativeTime !== time) return;
|
||||
const release = claimMutation(busy);
|
||||
if (release === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const completion = preview
|
||||
? Promise.resolve()
|
||||
: completeGuidedBirthTimeCandidate({ caseId: turn.caseId, resultId, time });
|
||||
void completion
|
||||
.then(() => onCandidateComplete(turn, time))
|
||||
.catch((caught) => setError(caught instanceof Error ? caught.message : "候选时间暂时无法保存"))
|
||||
.finally(() => {
|
||||
release();
|
||||
setPending(false);
|
||||
});
|
||||
};
|
||||
const retryScoring = () => {
|
||||
const turn = journey;
|
||||
if (preview && turn?.journeyProtocol === "dynamic-choice-v2"
|
||||
@@ -253,6 +276,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
|
||||
resume,
|
||||
editBirthTimeDetails: onEditBirthTimeDetails,
|
||||
acknowledgeReady,
|
||||
completeCandidate,
|
||||
retryScoring,
|
||||
saveCandidate,
|
||||
confirmCandidate,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
type CandidateCompletionRequest = {
|
||||
readonly caseId: string;
|
||||
readonly resultId: string;
|
||||
readonly time: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object"
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
export function candidateWorkingTime(
|
||||
stored: unknown,
|
||||
request: CandidateCompletionRequest,
|
||||
): string | null {
|
||||
const assessment = record(stored);
|
||||
const candidate = record(assessment?.candidate_result);
|
||||
const winner = record(candidate?.winningSegment);
|
||||
const turn = record(assessment?.turn_state);
|
||||
const action = record(turn?.nextAction);
|
||||
const actionKind = action?.kind;
|
||||
const terminal = actionKind === "present_low_result"
|
||||
|| actionKind === "present_medium_result"
|
||||
|| actionKind === "candidate_saved";
|
||||
|
||||
return assessment?.id === request.caseId
|
||||
&& assessment.user_id
|
||||
&& assessment.status === "candidate"
|
||||
&& assessment.candidate_result_id === request.resultId
|
||||
&& action?.resultId === request.resultId
|
||||
&& terminal
|
||||
&& winner?.representativeTime === request.time
|
||||
? request.time
|
||||
: null;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type DraftRevision = GuidedMutation & {
|
||||
};
|
||||
type CandidateSave = GuidedMutation & { readonly resultId: string };
|
||||
type CandidateConfirmation = CandidateSave & { readonly time: string };
|
||||
type CandidateCompletion = Pick<CandidateSave, "caseId" | "resultId"> & { readonly time: string };
|
||||
|
||||
const errorPayloadSchema = z.object({
|
||||
message: z.string().optional(),
|
||||
@@ -68,3 +69,22 @@ export function confirmGuidedBirthTimeCandidate(
|
||||
) {
|
||||
return send({ type: "confirm_guided_candidate", ...input });
|
||||
}
|
||||
|
||||
export async function completeGuidedBirthTimeCandidate(
|
||||
input: CandidateCompletion,
|
||||
) {
|
||||
const { response, payload } = await postJson({
|
||||
url: "/api/birth-time-candidate-completion",
|
||||
body: JSON.stringify(input),
|
||||
retryLostResponse: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const parsed = errorPayloadSchema.safeParse(payload);
|
||||
throw new GuidedBirthTimeRequestError(
|
||||
response.status,
|
||||
parsed.success
|
||||
? parsed.data.message ?? parsed.data.error ?? "候选时间暂时无法保存"
|
||||
: "候选时间暂时无法保存",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
|
||||
|
||||
export type GuidedTerminalPath = {
|
||||
readonly kind: "edit_birth_time_details";
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: false;
|
||||
};
|
||||
export type GuidedTerminalPath =
|
||||
| {
|
||||
readonly kind: "edit_birth_time_details";
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: false;
|
||||
}
|
||||
| {
|
||||
readonly kind: "complete_with_candidate";
|
||||
readonly time: string;
|
||||
readonly preservesCase: true;
|
||||
readonly appliesCandidateTime: true;
|
||||
};
|
||||
|
||||
export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null {
|
||||
const kind = journey.nextAction.kind;
|
||||
const winner = journey.candidateResult?.winningSegment;
|
||||
if (journey.journeyProtocol === "dynamic-choice-v2"
|
||||
&& winner
|
||||
&& (kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved")) {
|
||||
return {
|
||||
kind: "complete_with_candidate",
|
||||
time: winner.representativeTime,
|
||||
preservesCase: true,
|
||||
appliesCandidateTime: true,
|
||||
};
|
||||
}
|
||||
return kind === "present_low_result" || kind === "candidate_saved"
|
||||
? { kind: "edit_birth_time_details", preservesCase: true, appliesCandidateTime: false }
|
||||
: null;
|
||||
|
||||
@@ -69,6 +69,32 @@ export const birthTimePeriodOptions = [
|
||||
{ value: "late_night", label: "深夜(23:00—03:59)" },
|
||||
] as const;
|
||||
|
||||
export type BirthTimeDisplayState = {
|
||||
readonly kind: "candidate" | "confirmed";
|
||||
readonly activeTime: string;
|
||||
readonly reportedLabel: string;
|
||||
};
|
||||
|
||||
function reportedBirthTimeLabel(draft: BirthTimeDraft): string {
|
||||
if (draft.birthTimeSource === "period_only") {
|
||||
return birthTimePeriodOptions.find((option) => option.value === draft.birthTimePeriod)?.label
|
||||
?? "未选择时段";
|
||||
}
|
||||
if (draft.birthTimeSource === "unknown") return "具体时间未知";
|
||||
return draft.reportedTime || draft.time || "尚未填报";
|
||||
}
|
||||
|
||||
export function birthTimeDisplayState(draft: BirthTimeDraft): BirthTimeDisplayState | null {
|
||||
if (!draft.time || (draft.birthTimeStatus !== "candidate" && draft.birthTimeStatus !== "confirmed")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: draft.birthTimeStatus,
|
||||
activeTime: draft.time,
|
||||
reportedLabel: reportedBirthTimeLabel(draft),
|
||||
};
|
||||
}
|
||||
|
||||
const periodLabels = {
|
||||
"": "未选择时段",
|
||||
early_morning: "凌晨或清晨",
|
||||
@@ -126,6 +152,11 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
|
||||
return Boolean(draft.time)
|
||||
&& (draft.birthTimeStatus === "candidate" || draft.birthTimeStatus === "confirmed");
|
||||
}
|
||||
|
||||
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
|
||||
const reportedTime = draft.reportedTime || draft.time || null;
|
||||
const uncertainty = draft.birthTimeSource === "hospital_record"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const consultationEntrypointSchema = z.enum([
|
||||
"daily_starlanguage",
|
||||
"birth_time_rectification",
|
||||
]);
|
||||
|
||||
export type ConsultationEntrypoint = z.infer<typeof consultationEntrypointSchema>;
|
||||
|
||||
type ConsultationQuestionInput = {
|
||||
readonly visibleQuestion: string;
|
||||
readonly entrypoint: ConsultationEntrypoint | undefined;
|
||||
readonly currentDate: string;
|
||||
};
|
||||
|
||||
export type ResolvedConsultationQuestion =
|
||||
| { readonly kind: "plain"; readonly modelQuestion: string }
|
||||
| { readonly kind: "expanded"; readonly modelQuestion: string };
|
||||
|
||||
export function resolveConsultationQuestion(
|
||||
input: ConsultationQuestionInput,
|
||||
): ResolvedConsultationQuestion {
|
||||
switch (input.entrypoint) {
|
||||
case undefined:
|
||||
return { kind: "plain", modelQuestion: input.visibleQuestion };
|
||||
case "daily_starlanguage":
|
||||
return {
|
||||
kind: "expanded",
|
||||
modelQuestion: [
|
||||
`请结合已校验的星盘资料,深入解读 ${input.currentDate} 的今日主题。`,
|
||||
"请说明今日趋势、适合推进的事、需要避开的事,以及一个可以立即执行的行动建议。",
|
||||
"这是探索性日提示,不是确定预测;精确事件日期只能标为候选触发,不能包装成必然结论。",
|
||||
].join("\n"),
|
||||
};
|
||||
case "birth_time_rectification":
|
||||
return {
|
||||
kind: "expanded",
|
||||
modelQuestion: [
|
||||
"请基于已校验的出生资料继续进行生时校正辅助。",
|
||||
"先判断现有证据与候选结果,再说明最有区分度的下一步;需要补充信息时优先给用户可点击、容易回答的选项。",
|
||||
"候选时间必须标为待验证,不能声称是出生记录中的确定分钟,也不能在没有新证据时循环重启相同流程。",
|
||||
].join("\n"),
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = input.entrypoint;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("onboarding keeps the current card visible while birth-time assessment is pending", () => {
|
||||
// Given: saving the selected time can span profile persistence and assessment requests.
|
||||
const source = [
|
||||
readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"),
|
||||
readFileSync(new URL("../src/components/birth-time-assessment-overlay.tsx", import.meta.url), "utf8"),
|
||||
].join("\n");
|
||||
|
||||
// When: the UI enters either pending phase.
|
||||
// Then: the current form stays mounted and exposes one busy status surface.
|
||||
assert.match(source, /birthTimeAssessmentPhase/);
|
||||
assert.match(source, /const onboardingCardReady = presetMessageFinished \|\| birthTimeAssessmentPhase !== null/);
|
||||
assert.match(source, /onboardingStep === "birth" && onboardingCardReady/);
|
||||
assert.match(source, /onboardingStep === "place" && onboardingCardReady/);
|
||||
assert.match(source, /className="birth-time-assessment-overlay"/);
|
||||
assert.match(source, /aria-busy=\{birthTimeAssessmentPhase !== null\}/);
|
||||
assert.match(source, /previewMode === "birth-time-assessment-loading"/);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion.ts";
|
||||
|
||||
const terminalCase = {
|
||||
id: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
|
||||
user_id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
|
||||
status: "candidate",
|
||||
candidate_result_id: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||
candidate_result: {
|
||||
confidence: "medium",
|
||||
winningSegment: { representativeTime: "04:53" },
|
||||
},
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_medium_result",
|
||||
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("candidate completion only accepts the persisted terminal representative time", () => {
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
}), "04:53");
|
||||
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:54",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("non-terminal cases cannot be adopted for consultation", () => {
|
||||
assert.equal(candidateWorkingTime({
|
||||
...terminalCase,
|
||||
turn_state: { nextAction: { kind: "ask_dynamic_choice" } },
|
||||
}, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
}), null);
|
||||
});
|
||||
@@ -237,6 +237,7 @@ test("guide requests are strict and bound the natural-language message", () => {
|
||||
test("route authenticates before body parsing and has no privileged workflow imports", () => {
|
||||
const source = readFileSync(new URL("../src/app/api/birth-time-guide/route.ts", import.meta.url), "utf8");
|
||||
assert.ok(source.indexOf("auth.getUser") < source.indexOf("requestPayload(request)"));
|
||||
assert.match(source, /BirthTimeJourneyEngineConfigurationError/);
|
||||
for (const forbidden of [
|
||||
"begin_consultation_credit",
|
||||
"getJyotishAgent",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { confirmReviewedBirthTimeDraft } from "../src/lib/birth-time-guided-draft-confirmation.ts";
|
||||
import { guidedTerminalPath } from "../src/lib/birth-time-guided-terminal.ts";
|
||||
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
|
||||
import { dynamicBirthTimePreview } from "../src/lib/birth-time-dynamic-preview.ts";
|
||||
import { guidedBirthTimePreview } from "../src/lib/birth-time-guided-preview.ts";
|
||||
|
||||
test("draft revision publishes its new version before confirmation can fail", async () => {
|
||||
@@ -53,6 +54,17 @@ test("low without a result and saved medium both return to declared-time editing
|
||||
});
|
||||
});
|
||||
|
||||
test("dynamic medium terminal completes with its candidate working time", () => {
|
||||
const medium = dynamicBirthTimePreview("medium");
|
||||
|
||||
assert.deepEqual(guidedTerminalPath(medium), {
|
||||
kind: "complete_with_candidate",
|
||||
time: "05:43",
|
||||
preservesCase: true,
|
||||
appliesCandidateTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("request identity cache and scheduled polling deduplicate Strict Mode starts", async () => {
|
||||
const cache = createIdentityRequestCache<number>();
|
||||
let loads = 0;
|
||||
@@ -107,6 +119,15 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
|
||||
assert.doesNotMatch(candidateSource, /controller\.finish/);
|
||||
});
|
||||
|
||||
test("completed rectification transcript does not repeat the birth place turn", () => {
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
pageSource,
|
||||
/\{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace\(profileDraft\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("journey turn implementation stays within the 250 pure-LOC boundary", () => {
|
||||
const source = readFileSync(new URL("../src/lib/birth-time-journey-turn.ts", import.meta.url), "utf8");
|
||||
const pureLines = source.split("\n").filter((line) => {
|
||||
|
||||
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
assistantIntentCopy,
|
||||
birthTimeDisplayState,
|
||||
birthTimePersistenceValues,
|
||||
describeBirthTimeDraft,
|
||||
formatBirthDate,
|
||||
isBirthTimeReadyForConsultation,
|
||||
isBirthTimeDraftReady,
|
||||
parseBirthDate,
|
||||
type BirthTimeDraft,
|
||||
@@ -45,6 +47,39 @@ test("birth time intake requires only the fields selected by the source", () =>
|
||||
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
|
||||
});
|
||||
|
||||
test("a persisted candidate working time can leave rectification onboarding", () => {
|
||||
const candidate = {
|
||||
...emptyDraft,
|
||||
time: "04:53",
|
||||
birthTimeStatus: "candidate",
|
||||
} satisfies BirthTimeDraft;
|
||||
|
||||
assert.equal(isBirthTimeReadyForConsultation(candidate), true);
|
||||
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, time: "" }), false);
|
||||
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "rectifying" }), false);
|
||||
});
|
||||
|
||||
test("a persisted candidate working time takes precedence over the reported range", () => {
|
||||
// Given: rectification saved a candidate minute while preserving the user's original period.
|
||||
const candidate = {
|
||||
...emptyDraft,
|
||||
time: "04:53",
|
||||
birthTimeSource: "period_only",
|
||||
birthTimePeriod: "early_morning",
|
||||
birthTimeStatus: "candidate",
|
||||
} satisfies BirthTimeDraft;
|
||||
|
||||
// When: a profile surface asks what birth-time state to display.
|
||||
const display = birthTimeDisplayState(candidate);
|
||||
|
||||
// Then: the candidate minute is primary and the original period remains secondary.
|
||||
assert.deepEqual(display, {
|
||||
kind: "candidate",
|
||||
activeTime: "04:53",
|
||||
reportedLabel: "凌晨 / 清晨(04:00—07:59)",
|
||||
});
|
||||
});
|
||||
|
||||
test("birth time declaration payload cannot write deterministic application fields", () => {
|
||||
const draft = {
|
||||
...emptyDraft,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
consultationEntrypointSchema,
|
||||
resolveConsultationQuestion,
|
||||
} from "../src/lib/consultation-entrypoint.ts";
|
||||
|
||||
test("plain consultation questions remain user-authored", () => {
|
||||
// Given: an ordinary question without a product entrypoint.
|
||||
const visibleQuestion = "未来半年适合换工作吗?";
|
||||
|
||||
// When: the server resolves the model-facing question.
|
||||
const resolved = resolveConsultationQuestion({
|
||||
visibleQuestion,
|
||||
entrypoint: undefined,
|
||||
currentDate: "2026-07-19",
|
||||
});
|
||||
|
||||
// Then: the server does not rewrite ordinary user input.
|
||||
assert.deepEqual(resolved, { kind: "plain", modelQuestion: visibleQuestion });
|
||||
});
|
||||
|
||||
test("daily entrypoint selects a private server expansion", () => {
|
||||
// Given: the public short label and its closed entrypoint identity.
|
||||
const visibleQuestion = "深入看今日";
|
||||
|
||||
// When: the server resolves the request.
|
||||
const resolved = resolveConsultationQuestion({
|
||||
visibleQuestion,
|
||||
entrypoint: "daily_starlanguage",
|
||||
currentDate: "2026-07-19",
|
||||
});
|
||||
|
||||
// Then: routing is explicit and the model receives more than the public label.
|
||||
assert.equal(resolved.kind, "expanded");
|
||||
assert.notEqual(resolved.modelQuestion, visibleQuestion);
|
||||
});
|
||||
|
||||
test("birth-time entrypoint selects a private server expansion", () => {
|
||||
// Given: a completed profile starts another rectification from a public label.
|
||||
const visibleQuestion = "再次校正";
|
||||
|
||||
// When: the server resolves the request.
|
||||
const resolved = resolveConsultationQuestion({
|
||||
visibleQuestion,
|
||||
entrypoint: "birth_time_rectification",
|
||||
currentDate: "2026-07-19",
|
||||
});
|
||||
|
||||
// Then: the model question is expanded without changing the visible transcript.
|
||||
assert.equal(resolved.kind, "expanded");
|
||||
assert.notEqual(resolved.modelQuestion, visibleQuestion);
|
||||
});
|
||||
|
||||
test("consultation entrypoints form a closed public request enum", () => {
|
||||
assert.equal(consultationEntrypointSchema.safeParse("daily_starlanguage").success, true);
|
||||
assert.equal(consultationEntrypointSchema.safeParse("birth_time_rectification").success, true);
|
||||
assert.equal(consultationEntrypointSchema.safeParse("client_prompt").success, false);
|
||||
});
|
||||
|
||||
test("browser source does not own private entrypoint prompts", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /function buildDailyStarlanguageQuestion/);
|
||||
assert.doesNotMatch(source, /function buildBirthTimeRectificationQuestion/);
|
||||
assert.doesNotMatch(source, /请结合已校验的星盘资料/);
|
||||
assert.doesNotMatch(source, /请基于已校验的出生资料继续/);
|
||||
});
|
||||
|
||||
test("composer keeps the public question and clears hidden routing after edits", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /chooseSuggestedQuestion\("深入看今日",\s*"timing",\s*"daily_starlanguage"\)/s);
|
||||
assert.match(source, /birthTimeDisplay \? "再次校正" : "生时校正",\s*"timing",\s*"birth_time_rectification"/s);
|
||||
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,\s*\{ role: "user", text: question \}\]/s);
|
||||
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
|
||||
assert.match(source, /onChange=\{\(event\) => \{\s*setDraft\(event\.target\.value\);\s*setDraftTheme\(null\);\s*setDraftEntrypoint\(null\);/s);
|
||||
assert.match(source, /setDraft\(pending\.question\);\s*setDraftTheme\(pending\.theme\);\s*setDraftEntrypoint\(pending\.entrypoint\);/s);
|
||||
});
|
||||
|
||||
test("consult route expands an optional entrypoint for both Agent and tool input", () => {
|
||||
const source = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /entrypoint:\s*consultationEntrypointSchema\.optional\(\)/);
|
||||
assert.match(source, /question:\s*resolvedQuestion\.modelQuestion/);
|
||||
assert.match(source, /resolvedQuestion\.modelQuestion,\s*"\\n需要查询星盘时/s);
|
||||
});
|
||||
|
||||
test("homepage entrypoints use two whole-card native actions", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const wholeCardActions = source.match(/className="product-entrypoint-hitarea"/g) ?? [];
|
||||
|
||||
assert.equal(wholeCardActions.length, 2);
|
||||
assert.doesNotMatch(source, /className="daily-starlanguage-heading">[\s\S]{0,180}<button/);
|
||||
});
|
||||
Reference in New Issue
Block a user