diff --git a/docs/superpowers/plans/2026-07-19-private-consultation-entrypoints.md b/docs/superpowers/plans/2026-07-19-private-consultation-entrypoints.md new file mode 100644 index 00000000..2f520e56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-private-consultation-entrypoints.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-19-private-consultation-entrypoints-design.md b/docs/superpowers/specs/2026-07-19-private-consultation-entrypoints-design.md new file mode 100644 index 00000000..09bf8cea --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-private-consultation-entrypoints-design.md @@ -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. diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3448cdd5..35fe9d04 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/src/app/api/birth-time-candidate-completion/route.ts b/frontend/src/app/api/birth-time-candidate-completion/route.ts new file mode 100644 index 00000000..90b4099b --- /dev/null +++ b/frontend/src/app/api/birth-time-candidate-completion/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/api/birth-time-guide/route.ts b/frontend/src/app/api/birth-time-guide/route.ts index eb44c5c3..2d1c6f17 100644 --- a/frontend/src/app/api/birth-time-guide/route.ts +++ b/frontend/src/app/api/birth-time-guide/route.ts @@ -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: "当前资料已保留,请稍后重试。" }, diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index e0102815..e5e24635 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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, 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"), diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 12fe2a9a..ed6b1a11 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -45,7 +45,9 @@ function hasCompleteBirthProfile(profile: Record) { 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, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index dd70826f..ba1c1c03 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 53b2c847..9885716c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); + const [draftEntrypoint, setDraftEntrypoint] = useState(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(null); const [birthTimeError, setBirthTimeError] = useState(""); + const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState(null); const [startGreeting, setStartGreeting] = useState(""); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversationEnd = useRef(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) { @@ -2306,8 +2353,8 @@ export default function Home() { {(onboardingStep !== "name" || profileComplete) && } {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } {(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && } - {onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && } - {onboardingStep === "rectification" && birthTimeJourney && } + {!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && } + {!profileComplete && onboardingStep === "rectification" && birthTimeJourney && } {profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && } {profileComplete && onboardingJustCompleted && } @@ -2318,27 +2365,33 @@ export default function Home() { : startGreeting || `${profile.name.trim()},从你此刻最关心的问题开始吧。`)} /> )} - {!profileComplete && onboardingStep === "birth" && presetMessageFinished && ( + {!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
-
+
出生时间按你实际知道的程度填写,不需要猜测
- setProfileDraft((current) => ({ ...current, ...patch }))} /> - {accountError &&

{accountError}

} -
+
+ setProfileDraft((current) => ({ ...current, ...patch }))} /> + {accountError &&

{accountError}

} +
+
+
)} - {!profileComplete && onboardingStep === "place" && presetMessageFinished && ( + {!profileComplete && onboardingStep === "place" && onboardingCardReady && (
-
+
出生地点目前先支持中国大陆地区
- - {accountError &&

{accountError}

} -
+
+ + {accountError &&

{accountError}

} +
+
+
@@ -2371,29 +2424,61 @@ export default function Home() { ) : (
-
+
+
今日趋势
{dailyStarlanguage?.trend}
行动建议
{dailyStarlanguage?.action}
今日提醒
{dailyStarlanguage?.caution}
- 探索性日提示,不是确定预测。 +
+ 探索性日提示,不是确定预测。 + +
-
+
+
-
候选出生时间段
{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}
-
候选点
{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}
-
问题数
{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}
+ {birthTimeDisplay ? ( + <> +
{birthTimeDisplay.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}
{birthTimeDisplay.activeTime}
+
结果状态
{birthTimeDisplay.kind === "candidate" ? "候选时间(已用于排盘)" : "已确认"}
+
原始填报
{birthTimeDisplay.reportedLabel}
+ + ) : ( + <> +
候选出生时间段
{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}
+
候选点
{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}
+
问题数
{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}
+ + )}
- 不能直接改写默认星盘;需事件证据验证。 +
+ {birthTimeDisplay?.kind === "candidate" + ? "当前使用候选时间排盘;原始填报范围仍保留。" + : birthTimeDisplay?.kind === "confirmed" + ? "当前排盘时间已经确认。" + : "不能直接改写默认星盘;需事件证据验证。"} + +
{(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} diff --git a/frontend/src/components/birth-time-assessment-overlay.tsx b/frontend/src/components/birth-time-assessment-overlay.tsx new file mode 100644 index 00000000..f44a23a7 --- /dev/null +++ b/frontend/src/components/birth-time-assessment-overlay.tsx @@ -0,0 +1,35 @@ +export type BirthTimeAssessmentPhase = "saving_profile" | "assessing"; + +const progressCopy = { + saving_profile: { + title: "正在保存出生资料", + detail: "已保留你刚才的选择,马上开始生成生时评估。", + }, + assessing: { + title: "正在生成生时评估", + detail: "正在读取已有资料并准备下一步,不需要重复操作。", + }, +} as const satisfies Record; + +export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) { + if (phase === null) return null; + const copy = progressCopy[phase]; + + return ( +
+
+ + {copy.title} + {copy.detail} +
+
+ ); +} diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index 4665eb8b..0b3471dd 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -24,7 +24,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul return (

系统不会选择或应用未经证据支持的具体分钟,当前排盘使用时间保持不变。

- {terminalPath && } + {terminalPath && }
); } @@ -52,13 +52,13 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul {action.kind === "present_low_result" && (

{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}

- {terminalPath && } + {terminalPath && }
)} {action.kind === "present_medium_result" && winner && dynamic && (

已形成较窄的候选范围,本次评估已结束;它不会自动改动当前排盘使用时间

- + {terminalPath && }
)} {action.kind === "present_medium_result" && winner && !dynamic && ( @@ -72,7 +72,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul {action.kind === "candidate_saved" && (

候选时间范围已保存。当前排盘使用时间没有改变。

- {terminalPath && } + {terminalPath && }
)} {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>; }) { + if (path.kind === "complete_with_candidate") { + return ( +
+ + 将以 {path.time} 作为当前工作排盘时间;候选状态和原始资料都会保留。 +
+ ); + } return (
diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index d52d0843..39834313 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -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 (
+ {displayState && ( +
+
+ 生时校正结果 + {displayState.kind === "candidate" ? "候选时间" : "已确认"} +
+
+
+
{displayState.kind === "candidate" ? "当前工作排盘时间" : "当前排盘时间"}
+
{displayState.activeTime}
+
+
+
原始填报
+
{displayState.reportedLabel}
+
+
+ {displayState.kind === "candidate" && ( +

已用于当前排盘,但仍保留为候选结果,不会标记成出生记录中的确定分钟。

+ )} +
+ )} 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, diff --git a/frontend/src/lib/birth-time-candidate-completion.ts b/frontend/src/lib/birth-time-candidate-completion.ts new file mode 100644 index 00000000..dca767a1 --- /dev/null +++ b/frontend/src/lib/birth-time-candidate-completion.ts @@ -0,0 +1,36 @@ +type CandidateCompletionRequest = { + readonly caseId: string; + readonly resultId: string; + readonly time: string; +}; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : 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; +} diff --git a/frontend/src/lib/birth-time-guided-client.ts b/frontend/src/lib/birth-time-guided-client.ts index 48261848..89bd035c 100644 --- a/frontend/src/lib/birth-time-guided-client.ts +++ b/frontend/src/lib/birth-time-guided-client.ts @@ -16,6 +16,7 @@ type DraftRevision = GuidedMutation & { }; type CandidateSave = GuidedMutation & { readonly resultId: string }; type CandidateConfirmation = CandidateSave & { readonly time: string }; +type CandidateCompletion = Pick & { 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 ?? "候选时间暂时无法保存" + : "候选时间暂时无法保存", + ); + } +} diff --git a/frontend/src/lib/birth-time-guided-terminal.ts b/frontend/src/lib/birth-time-guided-terminal.ts index c0460545..bcd2de3f 100644 --- a/frontend/src/lib/birth-time-guided-terminal.ts +++ b/frontend/src/lib/birth-time-guided-terminal.ts @@ -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; diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index 7a3d6d91..5f8fd379 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -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" diff --git a/frontend/src/lib/consultation-entrypoint.ts b/frontend/src/lib/consultation-entrypoint.ts new file mode 100644 index 00000000..b9bd6b8c --- /dev/null +++ b/frontend/src/lib/consultation-entrypoint.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const consultationEntrypointSchema = z.enum([ + "daily_starlanguage", + "birth_time_rectification", +]); + +export type ConsultationEntrypoint = z.infer; + +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; + } + } +} diff --git a/frontend/tests/birth-time-assessment-loading.test.ts b/frontend/tests/birth-time-assessment-loading.test.ts new file mode 100644 index 00000000..34aa4ce8 --- /dev/null +++ b/frontend/tests/birth-time-assessment-loading.test.ts @@ -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"/); +}); diff --git a/frontend/tests/birth-time-candidate-completion.test.ts b/frontend/tests/birth-time-candidate-completion.test.ts new file mode 100644 index 00000000..3955e82c --- /dev/null +++ b/frontend/tests/birth-time-candidate-completion.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-time-guide-route.test.ts b/frontend/tests/birth-time-guide-route.test.ts index 6e29f0a3..1544837e 100644 --- a/frontend/tests/birth-time-guide-route.test.ts +++ b/frontend/tests/birth-time-guide-route.test.ts @@ -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", diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 2baa322c..f96acedc 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -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(); 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) => { diff --git a/frontend/tests/birth-time-intake.test.ts b/frontend/tests/birth-time-intake.test.ts index 45892252..08ac8b03 100644 --- a/frontend/tests/birth-time-intake.test.ts +++ b/frontend/tests/birth-time-intake.test.ts @@ -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, diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts new file mode 100644 index 00000000..37589e0d --- /dev/null +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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}