diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 4bc56e0b..3448cdd5 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -101,6 +101,14 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Copy:** labels describe what the user actually knows. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time. - **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable. - **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation. +- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring. +- **Candidate result:** keep the reported range, candidate interval, confidence, and active-time status visually separate. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute as the true birth time. +- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence. +- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. +- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey. +- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy. +- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute. +- **Guided responsive/accessibility contract:** body copy remains at least 14px, labels at least 12px, and all controls at least 44px. Focus is always visible, the composer is keyboard operable, semantic Chinese phrases remain together at 390px, and reduced-motion removes entrance translation while retaining state changes. ### Birth date picker @@ -202,3 +210,17 @@ Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motio ## 7. Depth & Surface Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows or atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals. + +## 8. Agent wording and accuracy boundary + +The Birth-Time Guide Agent controls wording only: it may choose a safe tone or extract a reviewable +draft, but it cannot select evidence domains, change `nextAction`, rank candidates, set confidence, +or grant save/apply permissions. Those decisions remain in the deterministic, versioned journey +state machine and its server-side scorer. + +`confidence` is an internal, versioned safety gate for this product flow. It is not an external oracle, +clinical claim, or proof from calibrated real cases. Until matching external-engine parity and real- +case validation are complete, low and medium results never apply a minute, and high results still +require explicit confirmation of the matching representative time. Journey telemetry is limited to +metric name, phase, and optional confidence; it never records messages, dates, coordinates, case IDs, +or user IDs. diff --git a/frontend/src/app/api/birth-time-guide/route.ts b/frontend/src/app/api/birth-time-guide/route.ts new file mode 100644 index 00000000..11eb8f58 --- /dev/null +++ b/frontend/src/app/api/birth-time-guide/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { birthTimeGuideRequestSchema } from "@/lib/birth-time-guide-agent"; +import { + BirthTimeGuideActionError, + createBirthTimeGuideService, +} from "@/lib/birth-time-guide-service"; +import { BirthTimeJourneyActionError, createJourneyTurnActions } from "@/lib/birth-time-journey-actions"; +import { + BirthTimeJourneyStoreError, + createSupabaseBirthTimeJourneyStore, +} from "@/lib/birth-time-journey-store"; +import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { getBirthTimeGuideAgent } from "@/mastra"; +import { defaultLanguageModel } from "@/mastra/model"; +import { + recordJourneyTransitionMetric, +} from "@/lib/birth-time-journey-telemetry"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +async function requestPayload(request: Request): Promise { + try { + return await request.json(); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +export async function POST(request: Request) { + let supabase: Awaited>; + try { + supabase = await createServerSupabaseClient(); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, + { status: 503 }, + ); + } + throw error; + } + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json( + { error: "请先登录", message: "登录后才能继续出生时间评估。" }, + { status: 401 }, + ); + } + + const parsed = birthTimeGuideRequestSchema.safeParse(await requestPayload(request)); + if (!parsed.success) { + return NextResponse.json( + { error: "生时引导请求格式不正确", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const store = createSupabaseBirthTimeJourneyStore(createAdminSupabaseClient()); + const actions = createJourneyTurnActions({ store }); + const model = defaultLanguageModel(); + const generator = model + ? { + async generate(prompt: string) { + const result = await getBirthTimeGuideAgent(model).generate([ + { role: "user", content: prompt }, + ]); + return { text: result.text }; + }, + } + : null; + const guide = createBirthTimeGuideService({ + generator, + loadCase: store.loadCase, + proposeEvidenceDraft: actions.proposeEvidenceDraft, + }); + + try { + switch (parsed.data.type) { + case "render_question": { + const response = await guide.renderQuestion(user.id, parsed.data.caseId); + return NextResponse.json(response); + } + case "draft_evidence": { + const response = await guide.draftEvidence(user.id, parsed.data); + recordJourneyTransitionMetric(response.turn, "turn_advanced"); + return NextResponse.json(response); + } + default: { + const exhaustive: never = parsed.data; + return exhaustive; + } + } + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "生时引导输出无效", message: "请检查当前问题后重试。" }, + { status: 409 }, + ); + } + if (error instanceof BirthTimeGuideActionError) { + const status = error.reason === "case_not_found" ? 404 : 409; + return NextResponse.json( + { error: "当前步骤不可用", message: "请刷新并使用最新的生时校正步骤。" }, + { status }, + ); + } + if (error instanceof StaleJourneyTurnError || error instanceof BirthTimeJourneyActionError) { + return NextResponse.json( + { error: "校正状态已更新", message: "请使用最新问题或草稿后重试。" }, + { status: 409 }, + ); + } + if (error instanceof BirthTimeJourneyStoreError) { + return NextResponse.json( + { error: "生时引导暂时不可用", message: "当前资料已保留,请稍后重试。" }, + { status: 503 }, + ); + } + throw error; + } +} diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index d87f1f85..30dae882 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -5,33 +5,31 @@ import { createJyotishBirthTimeJourneyEngine, BirthTimeJourneyEngineError, } from "@/lib/birth-time-journey-engine"; -import { - createBirthTimeJourneyService, - RectificationCaseNotFoundError, - RectificationQuestionsUnavailableError, -} from "@/lib/birth-time-journey-service"; -import { - createSupabaseBirthTimeJourneyStore, - BirthTimeJourneyStoreError, -} from "@/lib/birth-time-journey-store"; +import { createBirthTimeJourneyService, RectificationCaseNotFoundError, RectificationQuestionsUnavailableError, type VersionedJourneyResponse } from "@/lib/birth-time-journey-service"; +import { BirthTimeJourneyActionError } from "@/lib/birth-time-journey-actions"; +import { birthTimeJourneyRequestSchema } from "@/lib/birth-time-journey-request"; +import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence"; +import { CandidateConfirmationError } from "@/lib/birth-time-evidence"; +import { BirthTimeEvidenceContextError, EvidenceRectificationCaseNotFoundError, GuidedJourneyLegacyMutationError, StaleCandidateConfirmationError } from "@/lib/birth-time-evidence-service"; +import { createSupabaseBirthTimeJourneyStore, BirthTimeJourneyStoreError } from "@/lib/birth-time-journey-store"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { BirthTimeScoringJobError } from "@/lib/birth-time-scoring-job"; +import { GuidedCandidateActionError } from "@/lib/birth-time-guided-candidate"; +import { GuidedCandidateStoreConflictError } from "@/lib/birth-time-guided-candidate-store"; +import { JourneyTurnInvariantError } from "@/lib/birth-time-journey-turn"; +import { JourneyResponseInvariantError } from "@/lib/birth-time-journey-response-schema"; +import { + recordJourneyMetricEvent, + recordJourneyTransitionMetric, + recordScoringJourneyMetric, + type JourneyMetricName, +} from "@/lib/birth-time-journey-telemetry"; export const runtime = "nodejs"; export const maxDuration = 60; -const eventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("assess") }).strict(), - z.object({ type: z.literal("resume"), caseId: z.string().uuid() }).strict(), - z.object({ - type: z.literal("answer_question"), - caseId: z.string().uuid(), - questionId: z.string().trim().min(1).max(120), - answer: z.enum(["A", "B", "C", "D"]), - }).strict(), -]); - async function requestPayload(request: Request): Promise { try { return await request.json(); @@ -41,6 +39,15 @@ async function requestPayload(request: Request): Promise { } } +async function responseWithJourneyMetric( + action: Promise, + name: Extract, +): Promise { + const response = await action; + recordJourneyTransitionMetric(response, name); + return NextResponse.json(response); +} + export async function POST(request: Request) { let supabase: Awaited>; let journeyStoreClient: ReturnType; @@ -65,7 +72,7 @@ export async function POST(request: Request) { ); } - const parsed = eventSchema.safeParse(await requestPayload(request)); + const parsed = birthTimeJourneyRequestSchema.safeParse(await requestPayload(request)); if (!parsed.success) { return NextResponse.json( { error: "生时评估请求格式不正确", details: parsed.error.flatten() }, @@ -94,30 +101,89 @@ export async function POST(request: Request) { ); } const assessment = parseBirthTimeProfile(profile); - return NextResponse.json(await service.assess(user.id, assessment)); + return responseWithJourneyMetric(service.assess(user.id, assessment), "turn_advanced"); } case "answer_question": - return NextResponse.json(await service.answerQuestion( + return responseWithJourneyMetric(service.answerQuestion( user.id, parsed.data.caseId, parsed.data.questionId, parsed.data.answer, - )); - case "resume": - return NextResponse.json(await service.resume(user.id, parsed.data.caseId)); + ), "turn_advanced"); + case "resume": { + const response = await service.resume(user.id, parsed.data.caseId); + return NextResponse.json(response); + } + case "poll_scoring": + { + const before = await service.resume(user.id, parsed.data.caseId); + const response = await service.pollScoringJob(user.id, parsed.data.caseId, parsed.data.jobId); + recordScoringJourneyMetric(before, response); + return NextResponse.json(response); + } + case "submit_life_events": + return responseWithJourneyMetric(service.submitLifeEvents(user.id, parsed.data.caseId, parsed.data.events), "turn_advanced"); + case "save_candidate": + return responseWithJourneyMetric(service.saveCandidate(user.id, parsed.data.caseId, parsed.data.resultId), "turn_advanced"); + case "confirm_candidate": + return responseWithJourneyMetric(service.confirmCandidate(user.id, parsed.data.caseId, parsed.data.resultId, parsed.data.time), "turn_advanced"); + case "confirm_evidence_draft": + return responseWithJourneyMetric(service.confirmEvidenceDraft(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion, parsed.data.draftId), "turn_advanced"); + case "skip_evidence_question": + return responseWithJourneyMetric(service.skipEvidenceQuestion(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "turn_advanced"); + case "pause_rectification": + return responseWithJourneyMetric(service.pause(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "journey_paused"); + case "finish_rectification": + return responseWithJourneyMetric(service.finishWithCurrentRange(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "turn_advanced"); + case "revise_evidence_draft": + return responseWithJourneyMetric(service.reviseEvidenceDraft({ + userId: user.id, + caseId: parsed.data.caseId, + actionId: parsed.data.actionId, + expectedVersion: parsed.data.turnVersion, + precision: parsed.data.precision, + date: parsed.data.date, + }), "draft_corrected"); + case "save_guided_candidate": + return responseWithJourneyMetric(service.saveGuidedCandidate({ + userId: user.id, + caseId: parsed.data.caseId, + actionId: parsed.data.actionId, + expectedVersion: parsed.data.turnVersion, + resultId: parsed.data.resultId, + }), "turn_advanced"); + case "confirm_guided_candidate": + return responseWithJourneyMetric(service.confirmGuidedCandidate({ + userId: user.id, + caseId: parsed.data.caseId, + actionId: parsed.data.actionId, + expectedVersion: parsed.data.turnVersion, + resultId: parsed.data.resultId, + time: parsed.data.time, + }), "turn_advanced"); default: { const exhaustive: never = parsed.data; return exhaustive; } } } catch (error) { + if (error instanceof BirthTimeScoringJobError) { + recordJourneyMetricEvent({ kind: "error", reason: "scoring_failure", phase: "result" }); + } else if (error instanceof JourneyTurnInvariantError || error instanceof JourneyResponseInvariantError) { + recordJourneyMetricEvent({ kind: "error", reason: "illegal_state", phase: "result" }); + } if (error instanceof z.ZodError) { return NextResponse.json( { error: "出生资料尚未完成", message: "请检查出生时间情况和地点后重试。" }, { status: 409 }, ); } - if (error instanceof RectificationCaseNotFoundError) { + if ( + error instanceof RectificationCaseNotFoundError + || error instanceof EvidenceRectificationCaseNotFoundError + || (error instanceof BirthTimeJourneyActionError && error.reason === "case_not_found") + || (error instanceof GuidedCandidateActionError && error.reason === "case_not_found") + ) { return NextResponse.json( { error: "校正记录不存在", message: "请重新开始出生时间评估。" }, { status: 404 }, @@ -129,6 +195,29 @@ export async function POST(request: Request) { { status: 409 }, ); } + if ( + error instanceof StaleJourneyTurnError + || error instanceof BirthTimeJourneyActionError + || error instanceof GuidedJourneyLegacyMutationError + || error instanceof BirthTimeScoringJobError + || error instanceof GuidedCandidateActionError + || error instanceof GuidedCandidateStoreConflictError + ) { + return NextResponse.json( + { error: "校正状态已更新", message: "请使用最新问题或草稿后重试。" }, + { status: 409 }, + ); + } + if ( + error instanceof StaleCandidateConfirmationError + || error instanceof CandidateConfirmationError + || error instanceof BirthTimeEvidenceContextError + ) { + return NextResponse.json( + { error: "候选结果已变化", message: "请重新提交事件证据并确认最新候选结果。" }, + { status: 409 }, + ); + } if (error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) { return NextResponse.json( { error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" }, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1097eb6d..210cb575 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -126,8 +126,8 @@ button:disabled { cursor: default; opacity: .45; } .welcome-mark { margin-bottom: 20px; } .welcome h1, .auth-panel h1, .admin-header h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; } .welcome-copy { max-width: 520px; margin: 13px 0 28px; color: var(--color-ink-secondary); font-size: 15px; line-height: 1.65; } -.onboarding-card-reveal { animation: onboarding-card-enter 180ms var(--ease-out) both; } -.onboarding-card-reveal-inner { min-height: 0; } +.onboarding-card-reveal { min-width: 0; max-width: 100%; animation: onboarding-card-enter 180ms var(--ease-out) both; } +.onboarding-card-reveal-inner { min-width: 0; max-width: 100%; min-height: 0; } .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; } @@ -319,6 +319,8 @@ button:disabled { cursor: default; opacity: .45; } text-wrap: balance; } .onboarding-message { padding: 0 0 10px; padding-bottom: var(--space-3); } +.onboarding-message .message-markdown p { word-break: auto-phrase; text-wrap: pretty; } +.onboarding-message.is-phrase-safe .message-markdown p { overflow-wrap: break-word; } .onboarding-stream .message-markdown p:last-child::after { width: 2px; height: 1em; display: inline-block; margin-left: 3px; background: var(--color-action); vertical-align: -.12em; animation: onboarding-caret 700ms steps(1, end) infinite; content: ""; } .onboarding-stream.is-complete .message-markdown p:last-child::after { content: none; } .onboarding-card { border: 1px solid var(--color-border); max-width: 680px; margin: var(--space-2) 0 var(--space-5); padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } @@ -326,7 +328,7 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); } .birth-time-intake { display: grid; gap: var(--space-4); } .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: 11px; font-weight: 600; } +.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); } .birth-time-source-option { min-height: 64px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } .birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } @@ -338,29 +340,69 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-detail-grid > label { min-width: 0; } .birth-time-detail-note { padding-bottom: 12px; } .birth-time-legacy-note { padding: var(--space-3); border-left: 2px solid var(--color-warning); background: var(--color-canvas-muted); } -.birth-time-rectification { max-width: 680px; display: grid; gap: var(--space-5); } +.birth-time-rectification { width: 100%; min-width: 0; max-width: 680px; display: grid; box-sizing: border-box; gap: var(--space-5); } .birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } .birth-time-assessment-heading > div { display: grid; gap: var(--space-1); } .birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; } .birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; } -.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: 11px !important; letter-spacing: .04em !important; } +.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: var(--type-overline) !important; letter-spacing: .04em !important; } .birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } .birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); } -.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: 11px; } +.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } .birth-time-range-summary dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; } -.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; } +.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } .birth-time-question-list { display: grid; gap: var(--space-4); } .birth-time-question-progress { display: flex; justify-content: space-between; color: var(--color-ink-secondary); font-size: var(--type-caption); } .birth-time-question { display: grid; gap: var(--space-3); margin: 0; padding: var(--space-4) 0 0; border: 0; border-top: 1px solid var(--color-border); } .birth-time-question legend { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; line-height: 1.55; } -.birth-time-question legend span { width: 24px; height: 24px; display: inline-grid; flex: 0 0 24px; place-items: center; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xs); color: var(--color-ink-secondary); font-size: 11px; } +.birth-time-question legend span { width: 24px; height: 24px; display: inline-grid; flex: 0 0 24px; place-items: center; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xs); color: var(--color-ink-secondary); font-size: var(--type-overline); } .birth-time-answer-list { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); } .birth-time-answer-list button { min-height: 52px; display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; text-align: left; font-size: var(--type-caption); line-height: 1.4; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } .birth-time-answer-list button > span { color: var(--color-action); font-family: var(--font-mono); font-weight: 600; } .birth-time-answer-list button.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } -.birth-time-question > small { color: var(--color-ink-secondary); font-size: 11px; } +.birth-time-question > small { color: var(--color-ink-secondary); font-size: var(--type-overline); } +.birth-time-life-events { display: grid; gap: var(--space-4); } +.birth-time-evidence-note, .birth-time-evidence-boundary { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } +.birth-time-event-list { display: grid; gap: var(--space-3); } +.birth-time-event-row { display: grid; grid-template-columns: 1.1fr .9fr 1fr; gap: var(--space-3); margin: 0; padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); } +.birth-time-event-row legend { grid-column: 1 / -1; padding: 0 var(--space-1); color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; } +.birth-time-event-row label { min-width: 0; display: grid; gap: var(--space-2); } +.birth-time-event-row label > span { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 600; } +.birth-time-event-remove { min-height: 44px; grid-column: 1 / -1; justify-self: start; border: 0; padding: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-caption); } +.birth-time-evidence-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.birth-time-candidate-result { display: grid; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); } +.birth-time-candidate-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.birth-time-candidate-heading > b { color: var(--color-ink); font-size: var(--type-body-sm); } +.birth-time-candidate-heading > span { color: var(--color-action); font-size: var(--type-caption); font-weight: 600; } +.birth-time-candidate-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } +.birth-time-candidate-grid > div { display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); } +.birth-time-candidate-grid dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.birth-time-candidate-grid dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; } +.birth-time-confirmation-panel { display: grid; gap: var(--space-3); padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); } +.birth-time-confirmation-panel > b { color: var(--color-ink); font-size: var(--type-body-sm); } +.birth-time-confirmation-panel > p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } .birth-time-retry-card { display: grid; justify-items: start; gap: var(--space-3); } .birth-time-retry-card p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } +.birth-time-guide-turn, .birth-time-evidence-draft-card, .birth-time-scoring-status, .birth-time-candidate-terminal { min-width: 0; max-width: 100%; display: grid; gap: var(--space-4); } +.birth-time-guide-question { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } +.birth-time-guide-composer, .birth-time-draft-fields label { min-width: 0; display: grid; gap: var(--space-2); } +.birth-time-guide-composer > span, .birth-time-draft-fields label > span { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 600; } +.birth-time-guide-composer textarea { min-height: var(--space-24); resize: vertical; line-height: 1.55; } +.birth-time-guide-hint, .birth-time-scoring-status p, .birth-time-candidate-terminal p, .birth-time-success-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; word-break: auto-phrase; } +.birth-time-guided-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.birth-time-guided-action { min-height: 44px; padding-inline: var(--space-4); font-size: var(--type-body-sm); } +.button-text.birth-time-guided-action { border: 0; background: transparent; color: var(--color-action); cursor: pointer; } +.birth-time-draft-domain { margin: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); } +.birth-time-draft-domain > div { display: grid; gap: var(--space-1); } +.birth-time-draft-domain dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.birth-time-draft-domain dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); } +.birth-time-draft-fields { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } +.birth-time-scoring-status { padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); } +.birth-time-scoring-status > b { font-size: var(--type-body-sm); } +.birth-time-success-note { padding: var(--space-4); border-left: 2px solid var(--color-success); background: var(--color-success-muted); color: var(--color-success); } +.birth-time-candidate-terminal { justify-items: start; } +.birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; } +.phrase-nowrap { white-space: nowrap; } .starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; } .starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); } @@ -561,10 +603,13 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-panel h1 { font-size: var(--type-display-md); } .admin-header { padding: 0 var(--space-4); } .admin-scroll { padding: var(--space-6) var(--space-4) var(--space-12); } + .birth-time-event-row { grid-template-columns: 1fr; } } @media (max-width: 480px) { - .birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list { grid-template-columns: 1fr; } + .birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list, .birth-time-candidate-grid { 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); } .starter-content span { font-size: var(--type-title-sm); } .account-modal h2 { font-size: var(--type-title-lg); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 79198268..ed26d924 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -22,15 +22,19 @@ import { type BirthTimeDraft, type BirthTimeSource, } from "@/lib/birth-time-intake-model"; +import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; import { - answerBirthTimeQuestion, - parseJourneyResponse, requestBirthTimeAssessment, resumeBirthTimeJourney, - type JourneyAnswer, type JourneyClientResponse, } from "@/lib/birth-time-journey-client"; +import { + guidedBirthTimePreview, + isGuidedBirthTimePreview, + previewRectificationJourney, +} from "@/lib/birth-time-guided-preview"; import { keepFocusWithin } from "@/lib/focus-trap"; +import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; import { SessionModelPersistenceQueue, persistSessionModelSelection, @@ -105,30 +109,6 @@ const previewModelCatalog = parsePublicModelCatalog({ ], }); -const previewRectificationJourney = parseJourneyResponse({ - caseId: "7299894c-10a8-4b45-91d1-339007282c50", - snapshot: { - state: "rectifying", - assistantIntent: "start_standard_rectification", - input: "rectification_questions", - route: "rectification", - confidence: null, - canApply: false, - activeTime: null, - reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" }, - }, - questionnaire: { - questions: [ - { id: "education_shift", prompt: "求学阶段是否发生过一次明显的环境或方向变化?" }, - { id: "career_shift", prompt: "工作早期是否经历过一次清晰的行业、岗位或城市切换?" }, - { id: "relationship_milestone", prompt: "重要关系或婚姻节点是否集中在某个明确年份?" }, - ], - samples: [], - raw: {}, - }, - scoring: null, -}); - const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; const greetingVariants: Record string>> = { @@ -435,21 +415,22 @@ function AgentAvatar() { return