diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 614d2091..9c1521a0 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -17,6 +17,8 @@ services: interval: 30s timeout: 5s retries: 5 + start_period: 30s + start_interval: 1s web: image: ${WEB_IMAGE:-jyotisha-web:local} diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index d7b96c7e..91e9da69 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -122,8 +122,18 @@ The replayable public-case manifest currently contains 10 events: 5 career and 5 Prevention: add a new domain only after its public cases satisfy the same source/replay contract; never substitute loosely sourced celebrity notes or absence of a match for a negative conclusion. +## ERR-083 | Duplicate Supabase migration versions skipped required schema | resolved 2026-07-19 +Four migration pairs reused the same timestamp prefix, while Supabase records that prefix as the migration identity. The remote ledger therefore recorded only one file from each pair: `chart_profiles`, `synastry_reports`, and part of the profile recovery contract were absent even though their SQL files existed locally. Dynamic birth-time migrations were also still pending after the application branch was merged. + +Prevention: `tests/test_supabase_migration_versions.py` requires every migration prefix to be unique. Preserve already-recorded versions, move skipped SQL into later uniquely numbered repair migrations, run `supabase db push --linked --dry-run`, and verify the remote migration ledger plus live schema before deploying dependent application code. + ## Fragment Sweep Command Set +## ERR-086 | Steve Jobs jyotishganit artifacts used non-San-Francisco coordinates | mitigated 2026-07-21 +The jyotishganit Shadbala and field-comparison artifacts labelled the public case as San Francisco while using `37.3382, -122.0383`, unlike the canonical parity runner input `37.7749, -122.4194`. The affected artifacts are invalidated and production tuning remains blocked. Probe defaults now use the canonical coordinates; regeneration is still blocked in the active project virtualenv because `skyfield` is unavailable. + +Prevention: hash only normalized calculation-bearing input, require the location label and canonical coordinates to share one input contract, and reject an oracle artifact when its canonical input hash differs from the comparison packet. + ## ERR-084 | Pre-work fragment test assumes zero candidates despite current audited candidates | active 2026-07-19 `scripts/pre_work_check.py` reported `fragment_audit.candidate_count=2` on 2026-07-19 and reports `3` in the 2026-07-20 run, while `tests/test_preflight_fragment_scan.py` requires exactly zero. The pre-work command therefore cannot be reported green until the candidates are classified or the test is updated to validate the reviewed state rather than a hard-coded count. diff --git a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md index 873d5288..f2fa11d1 100644 --- a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md +++ b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md @@ -19,6 +19,33 @@ the tested deployment SHA. | ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. | | ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. | +## Initial production diagnosis (preserved) + +The original issue record captured these causes before implementation: + +- Chat deletion lacked both an authenticated `DELETE` grant and an owner-only + RLS policy. +- `startNewChat()` created only a chat session; the birth profile and + rectification case were account-scoped, so a new chat could not start an + explicit re-rectification while preserving the active chart. +- `dynamic-choice-v2` emitted `request_candidate_confirmation`, but the client + sent the legacy `confirm_guided_candidate` mutation, which rejected dynamic + cases and left them in `confirming` without applying the candidate. +- The reported `都不符合` failure coincided with a deployment replacement in + which Caddy returned HTTP 502 while Docker could not resolve the temporarily + unavailable `web` service. +- WebKit can represent a non-JSON parse failure as a `DOMException` named + `SyntaxError`; the old transport recognized JavaScript `SyntaxError` only and + could expose `The string did not match the expected pattern.` instead of + stable Chinese product copy. + +The original closure checklist required owner-only deletion, explicit +account-level re-rectification, atomic idempotent candidate confirmation, +deployment availability or bounded retry behavior, stable localized WebKit +errors, and authenticated end-to-end coverage for `都不符合`, confirmation, +re-rectification, and deletion. The table above maps each requirement to the +implemented local evidence and the remaining production proof. + ## Release decision All five issues remain `verified-local` until the production closure artifacts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 27b51bf3..5fc738e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", "tw-animate-css": "^1.4.0", @@ -10112,6 +10113,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd4f53ed..868d0f07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", "tw-animate-css": "^1.4.0", diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 6949651e..f0ee4fac 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -429,8 +429,13 @@ function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: str ["D4", "d4Sign"], ["D9", "d9Sign"], ["D10", "d10Sign"], + ["D2", "d2Sign"], + ["D11", "d11Sign"], ["D24", "d24Sign"], ["D30", "d30Sign"], + ["A7", "a7Sign"], + ["UL", "ulSign"], + ["A10", "a10Sign"], ] as const; const availableLayers = layers .filter(([, key]) => scan.samples.some((sample) => typeof sample[key] === "string" && sample[key]?.trim())) @@ -504,6 +509,7 @@ export async function buildProductionConversationalRectificationPacket( lon: place.longitude, tz: place.timezoneOffset, evidence: [], + events, dismissedOpportunityIds: [], questionFingerprints: [], partitionFingerprints: [], diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 568dde57..ab15475b 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -17,7 +17,7 @@ function createProfileRepository( async loadProfile(userId) { return admin .from("profiles") - .select("id,name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") + .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") .eq("id", userId) .maybeSingle(); }, @@ -64,7 +64,7 @@ export const POST = createOnboardingPost({ repository: createProfileRepository(admin), }; }, - generateText: async (name) => { + generateText: async (name, signal) => { const model = defaultLanguageModel(); if (!model) return null; const result = await getOnboardingAgent(model).generate([ @@ -75,7 +75,7 @@ export const POST = createOnboardingPost({ "请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。", ].join("\n"), }, - ]); + ], { abortSignal: signal }); return result.text; }, now: () => new Date(), diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5d26b307..cb60a1c7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -132,10 +132,9 @@ button:disabled { cursor: default; opacity: .45; } .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; } -.unverified-birth-time-choice-scrim { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); } -.unverified-birth-time-choice { width: min(100%, 620px); margin: 0; overflow: visible; } -.unverified-birth-time-choice .onboarding-card-actions { flex-wrap: wrap; gap: var(--space-2); } -.unverified-birth-time-choice button { min-height: 44px; } +[data-sonner-toaster] [data-sonner-toast] { border: 1px solid var(--color-border) !important; border-radius: var(--radius-lg) !important; background: var(--color-canvas) !important; color: var(--color-ink) !important; box-shadow: var(--shadow-elevated) !important; } +[data-sonner-toast] [data-title] { font-size: var(--type-body-sm); font-weight: 600; } +[data-sonner-toast] [data-description] { color: var(--color-ink-secondary) !important; font-size: var(--type-caption); line-height: 1.5; } .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; } @@ -269,7 +268,7 @@ button:disabled { cursor: default; opacity: .45; } .group\/sidebar-provider[data-viewport] { height: 100dvh; min-height: 0; overflow: hidden; } .chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); } -.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) var(--space-3); background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); } +.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); } [data-sidebar="header"] { flex: 0 0 auto; } [data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } [data-sidebar="footer"] { flex: 0 0 auto; } @@ -299,11 +298,11 @@ button:disabled { cursor: default; opacity: .45; } .session-actions button > svg { width: 16px; height: 16px; } .session-actions button:hover { background: var(--color-canvas-muted); color: var(--color-ink); } .session-actions .session-action-danger { color: var(--color-danger); } -.sidebar-footer { position: relative; margin-top: var(--space-3); padding-top: 10px; border-top: 1px solid var(--sidebar-border); } +.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); } .profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } .profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; } .profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; } -[data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } +[data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; } [data-sidebar="trigger"] svg { width: 18px; height: 18px; } [data-sidebar="trigger"]:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } [data-sidebar="trigger"]:active { transform: translateY(1px); } @@ -443,24 +442,34 @@ button:disabled { cursor: default; opacity: .45; } .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%; } +.rectification-session-surface { width: min(720px, 100%); min-width: 0; margin: 0 auto; } +.rectification-loading-state { min-height: min(52dvh, 420px); display: grid; align-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-8); color: var(--color-ink-secondary); text-align: center; } +.rectification-loading-state strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; } +.rectification-loading-state > span { font-size: var(--type-body-sm); } .conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); overflow-wrap: anywhere; color: var(--color-ink); } .conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; } .conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; } -.conversational-rectification :where(button, textarea, summary, .conversational-status):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } -.conversational-narrative { padding: var(--space-5); border-left: 3px solid var(--color-action); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; background: var(--color-action-soft); } -.conversational-narrative .message-markdown { overflow-wrap: anywhere; } +.conversational-rectification :where(button, textarea):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; } +.conversational-narrative { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-soft); } +.conversational-narrative-body { padding: var(--space-4) var(--space-5); } +.conversational-narrative .message-markdown { overflow-wrap: anywhere; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.8; } +.conversational-narrative .message-markdown > :first-child { margin-top: 0; } +.conversational-narrative .message-markdown > :last-child { margin-bottom: 0; } +.conversational-narrative .message-markdown h2, .conversational-narrative .message-markdown h3 { margin: var(--space-4) 0 var(--space-2); font-family: var(--font-sans); font-size: var(--type-body-md); font-weight: 600; letter-spacing: 0; } .conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } -.conversational-domain-picker legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.conversational-domain-picker .conversational-domain-question { margin-bottom: var(--space-2); color: var(--color-ink); font-size: var(--type-body-md); font-weight: 600; line-height: 1.5; } .conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); } .conversational-domain-picker button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; } .conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); } .conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } .conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; } .conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; } +.conversational-event-date { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.conversational-event-date label { gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 500; } .conversational-composer textarea { width: 100%; min-width: 0; min-height: 112px; resize: vertical; padding: var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); line-height: 1.55; } .conversational-composer-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } +.conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } .conversational-evidence-recap > header, .conversational-candidate > header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .conversational-evidence-recap h3, .conversational-candidate h3, .conversational-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } .conversational-evidence-recap > header span, .conversational-candidate > header span { color: var(--color-ink-secondary); font-size: var(--type-caption); } @@ -470,26 +479,16 @@ button:disabled { cursor: default; opacity: .45; } .conversational-evidence-recap li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-soft); } .conversational-evidence-recap li > div { min-width: 0; } .conversational-evidence-recap time { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.conversational-evidence-recap p, .conversational-candidate p, .conversational-confirmation p, .conversational-original-question p, .conversational-abandon-confirmation p, .conversational-empty-state p, .conversational-status p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } +.conversational-evidence-recap p, .conversational-candidate p, .conversational-confirmation p, .conversational-original-question p, .conversational-empty-state p, .conversational-status p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } .conversational-evidence-recap li button { min-width: 56px; padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } -.conversational-candidate dl, .conversational-technical-receipt dl { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } -.conversational-candidate dl > div, .conversational-technical-receipt dl > div { min-width: 0; display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); } -.conversational-candidate dt, .conversational-technical-receipt dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.conversational-candidate dd, .conversational-technical-receipt dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; } +.conversational-candidate dl { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } +.conversational-candidate dl > div { min-width: 0; display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); } +.conversational-candidate dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } +.conversational-candidate dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; } .conversational-candidate time { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } -.conversational-technical-receipt { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); } -.conversational-technical-receipt summary { min-height: 44px; display: flex; align-items: center; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; } -.conversational-technical-receipt code { font-family: var(--font-mono); font-size: var(--type-overline); } .conversational-confirmation { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); } .conversational-status { min-width: 0; min-height: 1px; } .conversational-original-question { background: var(--color-success-muted); } -.conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } -.conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; } -.conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); } -.conversational-abandon-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); } -.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); } -.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } -.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } .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; } @@ -762,10 +761,10 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); } @media (max-width: 430px) { + [data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; } .conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; } - .conversational-narrative, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { padding: var(--space-3); } - .conversational-domain-picker > div, .conversational-candidate dl, .conversational-technical-receipt dl { grid-template-columns: minmax(0, 1fr); } + .conversational-narrative-body, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { padding: var(--space-3); } + .conversational-domain-picker > div, .conversational-candidate dl { grid-template-columns: minmax(0, 1fr); } .conversational-composer-footer { align-items: stretch; flex-direction: column; } - .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button, .conversational-session-actions > button, .conversational-abandon-confirmation button { width: 100%; } - .conversational-session-actions, .conversational-abandon-confirmation > div { flex-direction: column; } + .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; } } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index fae44c11..4ff46db7 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Script from "next/script"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; import "./birth-time-choice.css"; @@ -30,7 +31,10 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac )} - {children} + + {children} + + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 734a045d..f0294821 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AppSidebar } from "@/components/app-sidebar"; import { @@ -11,15 +11,16 @@ import { type BirthTimeAssessmentPhase, } from "@/components/birth-time-assessment-overlay"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { BirthTimeSoftNotice } from "@/components/birth-time-soft-notice"; +import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; -import { UnverifiedBirthTimeChoice } from "@/components/unverified-birth-time-choice"; import { ModelSelector } from "@/components/model-selector"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { Textarea } from "@/components/ui/textarea"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; -import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply"; +import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { applyBirthTimeDraftPatch, @@ -35,14 +36,12 @@ import { } from "@/lib/birth-time-intake-model"; import { birthTimeConsultationOptionsCopy, - canUseUnverifiedBirthTime, clearBirthTimeConsultationConsent, createLatestAccountRequestGuard, createBirthTimeConsultationConsentState, grantBirthTimeConsultationConsent, resolveBirthTimeConsultationRoute, resolveRectificationCardAction, - unverifiedBirthTime, type AccountRectificationCaseState, type BirthTimeConsultationConsentState, type RectificationCardAction, @@ -98,15 +97,6 @@ const BirthTimeRectification = dynamic( }, ); -const ConversationalBirthTimeRectification = dynamic( - () => import("@/components/conversational-birth-time-rectification") - .then((module) => module.ConversationalBirthTimeRectification), - { - ssr: false, - loading: () =>

正在加载生时校正对话…

, - }, -); - type Theme = ReplyTheme; type Message = ChatMessage; type Profile = BirthTimeDraft & { @@ -149,7 +139,17 @@ type SynastryReportApiRecord = { report?: SynastryReportCard; created_at?: string; }; -type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number }; +type ChatSessionType = "consultation" | "birth_time_rectification"; +type ChatSession = { + id: string; + title: string; + theme: Theme; + modelId: string; + messages: Message[]; + updatedAt: number; + sessionType: ChatSessionType; + rectificationCaseId: string | null; +}; type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { label: string; lat: number; lon: number; tz: number }; @@ -172,12 +172,6 @@ type DailyStarlanguageApiResponse = { boundary?: "not_deterministic_prediction"; }; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; -type PendingBirthTimeChoice = Readonly<{ - sessionId: string; - question: string; - entrypoint: ConsultationEntrypoint | null; - theme: Theme; -}>; type ConsultationRectificationHandoff = Readonly<{ caseId: string; turnVersion: number; @@ -213,6 +207,20 @@ const rectificationCardLabels = { resume: "继续上次校正", revise: "再次校正", } as const satisfies Record; +const rectifyBeforeConsultationSuggestion = "先完成生时校正"; + +function RectificationLoadingState() { + return ( +
+ + 正在和星星核对校正进度 + 马上回到上次的生时校正 +
+ ); +} const accountDialogTitles = { profile: "个人资料", @@ -265,14 +273,19 @@ function timestamp() { return Date.now(); } -function createSession(modelId: string): ChatSession { +function createSession( + modelId: string, + sessionType: ChatSessionType = "consultation", +): ChatSession { return { id: globalThis.crypto.randomUUID(), - title: "新对话", + title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话", theme: "general", modelId, messages: [], updatedAt: timestamp(), + sessionType, + rectificationCaseId: null, }; } @@ -563,7 +576,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null const fallbackSessionIds: string[] = []; const sessions = value.flatMap((item): ChatSession[] => { if (!item || typeof item !== "object") return []; - const session = item as Partial & { model_id?: unknown; updated_at?: unknown }; + const session = item as Partial & { + model_id?: unknown; + rectification_case_id?: unknown; + session_type?: unknown; + updated_at?: unknown; + }; const messages: Message[] = Array.isArray(session.messages) ? session.messages.flatMap((message) => ( message && typeof message === "object" @@ -590,6 +608,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null theme: session.theme === "career" || session.theme === "marriage" || session.theme === "timing" ? session.theme : "general", modelId: selection.modelId, messages, + sessionType: session.session_type === "birth_time_rectification" + ? "birth_time_rectification" + : "consultation", + rectificationCaseId: typeof session.rectification_case_id === "string" + ? session.rectification_case_id + : null, updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : typeof session.updated_at === "string" @@ -770,8 +794,9 @@ export default function Home() { const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( createBirthTimeConsultationConsentState, ); - const [pendingBirthTimeChoice, setPendingBirthTimeChoice] = useState(null); - const [rectificationSurfaceOpen, setRectificationSurfaceOpen] = useState(false); + const [birthTimeSoftNotice, setBirthTimeSoftNotice] = useState(""); + const [rectificationSessionId, setRectificationSessionId] = useState(null); + const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); @@ -812,9 +837,12 @@ export default function Home() { const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); + const continueRectificationQuestion = useRef<(question: string) => void>(() => undefined); + const automaticRectificationContinuation = useRef(""); const durableRectificationQuestionHandoff = useRef( createDurableRectificationQuestionHandoffClient(), ); + const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); @@ -829,8 +857,9 @@ export default function Home() { }); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; - const activeBirthTimeChoice = pendingBirthTimeChoice?.sessionId === activeSession?.id - ? pendingBirthTimeChoice + const rectificationSurfaceOpen = activeSession?.sessionType === "birth_time_rectification"; + const visibleRectificationTurn = activeSession?.id === rectificationSessionId + ? rectificationInitialTurn : null; const visibleSessions = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) @@ -840,6 +869,7 @@ export default function Home() { const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending + || creatingSession || rectificationMutationPending || rectificationContinuationPending || !account @@ -857,6 +887,33 @@ export default function Home() { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); + const dismissBirthTimeSoftNotice = useCallback(() => setBirthTimeSoftNotice(""), []); + + useEffect(() => { + if (!hydrated + || !account + || !modelCatalog + || activeSession?.sessionType !== "birth_time_rectification" + || visibleRectificationTurn + || rectificationLoading + || rectificationMutationPending + || rectificationContinuationPending + || creatingSession + || rectificationError) return; + resumeRectificationSession.current(activeSession); + }, [ + account, + activeSession, + creatingSession, + hydrated, + modelCatalog, + rectificationContinuationPending, + rectificationError, + rectificationLoading, + rectificationMutationPending, + visibleRectificationTurn, + ]); + useEffect(() => { if (!hydrated || !accountId) return; const prefix = `jyotisha-session-controls:${accountId}:`; @@ -947,7 +1004,7 @@ export default function Home() { : onboardingStep === "rectification" && birthTimeJourney ? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent) : presetOnboardingMessage; - const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted; + const shouldStreamOnboarding = !profileComplete; const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length; const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null; @@ -1019,6 +1076,8 @@ export default function Home() { modelId: previewModelCatalog.defaultModelId, messages: previewMessages, updatedAt: timestamp(), + sessionType: "consultation", + rectificationCaseId: null, }; setAccount({ user: { id: "preview-user", email: "preview@local.test" }, @@ -1077,7 +1136,7 @@ export default function Home() { .maybeSingle(), supabase .from("chat_sessions") - .select("id,title,theme,model_id,messages,updated_at") + .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") .abortSignal(controller.signal) .order("updated_at", { ascending: false }), ]); @@ -1099,6 +1158,8 @@ export default function Home() { theme: initialSession.theme, model_id: initialSession.modelId || null, messages: initialSession.messages, + session_type: initialSession.sessionType, + rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), }) .abortSignal(controller.signal); @@ -1296,6 +1357,8 @@ export default function Home() { theme: session.theme, model_id: session.modelId, messages: session.messages, + session_type: session.sessionType, + rectification_case_id: session.rectificationCaseId, updated_at: new Date(session.updatedAt).toISOString(), }; if (mode === "create") { @@ -1338,7 +1401,6 @@ export default function Home() { const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); - setPendingBirthTimeChoice((current) => current?.sessionId === session.id ? null : current); setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); @@ -1417,10 +1479,17 @@ export default function Home() { } function selectSession(sessionId: string) { + const nextSession = sessions.find((session) => session.id === sessionId); setActiveSessionId(sessionId); setDraft(""); setDraftEntrypoint(null); setComposerNotice(""); + if (nextSession?.sessionType === "birth_time_rectification") { + setRectificationError(""); + if (nextSession.id !== rectificationSessionId || !rectificationInitialTurn) { + resumeRectificationSession.current(nextSession); + } + } } async function selectSessionModel(modelId: string) { @@ -1666,7 +1735,7 @@ export default function Home() { setPresetMessageLength(0); const nextStep = missingProfileStep(nextProfile); if (nextStep) setOnboardingStep(nextStep); - else setOnboardingJustCompleted(true); + else setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败")); } finally { @@ -1687,7 +1756,7 @@ export default function Home() { setPresetMessageLength(0); const nextStep = missingProfileStep(profileDraft); if (nextStep) setOnboardingStep(nextStep); - else setOnboardingJustCompleted(true); + else setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败")); } finally { @@ -1715,7 +1784,7 @@ export default function Home() { setProfileDraft(profileDraft); setStartGreeting(createStartGreeting(profileDraft.name)); setPresetMessageLength(0); - setOnboardingJustCompleted(true); + setOnboardingJustCompleted(false); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); } finally { @@ -1735,7 +1804,7 @@ export default function Home() { setProfile(confirmedProfile); setProfileDraft(confirmedProfile); setPresetMessageLength(0); - setOnboardingJustCompleted(true); + setOnboardingJustCompleted(false); } function completeCandidateBirthTime(result: JourneyClientResponse, time: string) { @@ -1750,7 +1819,7 @@ export default function Home() { setBirthTimeJourney(null); setPresetMessageLength(0); setStartGreeting(createStartGreeting(candidateProfile.name)); - setOnboardingJustCompleted(true); + setOnboardingJustCompleted(false); } async function retryBirthTimeAssessment() { @@ -1760,7 +1829,7 @@ export default function Home() { try { const assessedProfile = await assessSavedBirthTime(profileDraft); setPresetMessageLength(0); - if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true); + if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false); } catch (caught) { setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。"); } finally { @@ -1826,81 +1895,172 @@ export default function Home() { window.requestAnimationFrame(() => composerInput.current?.focus()); } + function chooseConversationSuggestion(suggestion: string) { + if (suggestion !== rectifyBeforeConsultationSuggestion + || activeSession?.sessionType !== "consultation") { + chooseSuggestedQuestion(suggestion); + return; + } + const originalQuestion = [...activeSession.messages] + .reverse() + .find((message) => message.role === "user")?.text.trim(); + if (!originalQuestion) { + void openBirthTimeRectification(null, activeSession); + return; + } + rectificationQuestionHandoff.current.capture({ + question: originalQuestion, + sessionId: activeSession.id, + theme: activeSession.theme, + }); + void openBirthTimeRectification(originalQuestion, activeSession); + } + function draftDailyStarlanguageQuestion() { chooseSuggestedQuestion("深入看今日", "timing", "daily_starlanguage"); } - function synchronizeRectificationQuestion(turn: ConversationalRectificationTurn) { - if (!turn.pendingConsultationQuestion || !activeSession) return; - rectificationQuestionHandoff.current.synchronizeDurableQuestion( - turn.pendingConsultationQuestion, - { sessionId: activeSession.id, theme: activeSession.theme }, - ); + function synchronizeRectificationQuestion( + turn: ConversationalRectificationTurn, + fallbackSession: ChatSession | undefined = activeSession, + ) { + if (!turn.pendingConsultationQuestion) return; + if (!rectificationQuestionHandoff.current.peek() && fallbackSession) { + rectificationQuestionHandoff.current.synchronizeDurableQuestion( + turn.pendingConsultationQuestion, + { sessionId: fallbackSession.id, theme: fallbackSession.theme }, + ); + } setRectificationPendingQuestion(turn.pendingConsultationQuestion); } - async function openBirthTimeRectification(pendingConsultationQuestion: string | null = null) { - if (!account || rectificationLoading || rectificationMutationPending + async function openBirthTimeRectification( + pendingConsultationQuestion: string | null = null, + sourceSessionOverride: ChatSession | null = null, + ) { + if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending || rectificationContinuationInFlight.current) return; + const sourceSession = sourceSessionOverride ?? activeSession; + if (!sourceSession) return; const action = resolveRectificationCardAction({ rectificationCase: account.rectificationCase, hasConfirmedBirthTime: account.hasConfirmedBirthTime, }); - setPendingBirthTimeChoice(null); + const resumableSession = action === "resume" && account.rectificationCase + ? sessions.find((session) => session.sessionType === "birth_time_rectification" + && session.rectificationCaseId === account.rectificationCase?.caseId) + ?? sessions.find((session) => session.sessionType === "birth_time_rectification" + && session.rectificationCaseId === null) + ?? null + : null; + const rectificationSession = sourceSession.sessionType === "birth_time_rectification" + ? sourceSession + : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); + const reusingRectificationSession = rectificationSession !== sourceSession + ? resumableSession !== null + : sourceSession.sessionType === "birth_time_rectification"; + const requestedQuestion = pendingConsultationQuestion + ?? (reusingRectificationSession + ? null + : rectificationQuestionHandoff.current.peek()?.question) + ?? null; setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); - setRectificationPendingQuestion( - pendingConsultationQuestion - ?? rectificationQuestionHandoff.current.peek()?.question - ?? null, - ); + setRectificationPendingQuestion(requestedQuestion); setRectificationInitialTurn(null); setRectificationError(""); - setRectificationSurfaceOpen(true); - setRectificationLoading(true); + + if (!reusingRectificationSession) { + setCreatingSession(true); + setSessions((current) => [rectificationSession, ...current]); + setRectificationReturnSessionId(sourceSession.id); + activeSessionIdRef.current = rectificationSession.id; + setActiveSessionId(rectificationSession.id); + setRectificationSessionId(rectificationSession.id); + setRectificationLoading(true); + try { + await persistSession(rectificationSession, "create"); + } catch (caught) { + setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); + activeSessionIdRef.current = sourceSession.id; + setActiveSessionId(sourceSession.id); + setRectificationSessionId(null); + setRectificationLoading(false); + setRequestError({ + sessionId: sourceSession.id, + message: caught instanceof Error ? caught.message : "生时校正会话未能保存到云端。", + }); + return; + } finally { + setCreatingSession(false); + } + } else { + if (sourceSession.id !== rectificationSession.id) { + setRectificationReturnSessionId(sourceSession.id); + } + activeSessionIdRef.current = rectificationSession.id; + setActiveSessionId(rectificationSession.id); + setRectificationSessionId(rectificationSession.id); + setRectificationLoading(true); + } try { + let turn: ConversationalRectificationTurn; if (action !== "resume" || !account.rectificationCase) { const durable = await durableRectificationQuestionHandoff.current.load(); - if (!durable || durable.status === "consumed") return; - setRectificationInitialTurn(durable.turn); - synchronizeRectificationQuestion(durable.turn); - return; - } - - let current = account.rectificationCase; - let turn: ConversationalRectificationTurn; - if (pendingConsultationQuestion) { - try { - turn = await durableRectificationQuestionHandoff.current.attach({ + turn = durable && durable.status !== "consumed" + ? durable.turn + : await sendConversationalRectificationCommand({ + type: "start", + actionId: globalThis.crypto.randomUUID(), + pendingConsultationQuestion: requestedQuestion, + }); + } else { + let current = account.rectificationCase; + if (pendingConsultationQuestion) { + try { + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } catch (error) { + if (!(error instanceof DurableRectificationHandoffError) + || error.status !== 409) throw error; + const latest = await fetchAccount(); + if (!latest.rectificationCase + || latest.rectificationCase.caseId !== current.caseId) throw error; + current = latest.rectificationCase; + setAccount(latest); + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } + } else { + turn = await sendConversationalRectificationCommand({ + type: "resume", caseId: current.caseId, + actionId: globalThis.crypto.randomUUID(), turnVersion: current.turnVersion, - question: pendingConsultationQuestion, - }); - } catch (error) { - if (!(error instanceof DurableRectificationHandoffError) - || error.status !== 409) throw error; - const latest = await fetchAccount(); - if (!latest.rectificationCase - || latest.rectificationCase.caseId !== current.caseId) throw error; - current = latest.rectificationCase; - setAccount(latest); - turn = await durableRectificationQuestionHandoff.current.attach({ - caseId: current.caseId, - turnVersion: current.turnVersion, - question: pendingConsultationQuestion, }); } - } else { - turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: current.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: current.turnVersion, - }); } + + const boundSession = rectificationSession.rectificationCaseId === turn.caseId + ? rectificationSession + : { ...rectificationSession, rectificationCaseId: turn.caseId, updatedAt: timestamp() }; setRectificationInitialTurn(turn); - synchronizeRectificationQuestion(turn); + synchronizeRectificationQuestion(turn, sourceSession); + if (boundSession !== rectificationSession) { + updateSession(rectificationSession.id, () => boundSession); + try { + await persistSession(boundSession); + } catch { + setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); + } + } } catch (caught) { setRectificationError(caught instanceof Error ? caught.message @@ -1910,10 +2070,26 @@ export default function Home() { } } + resumeRectificationSession.current = (session) => { + void openBirthTimeRectification(null, session); + }; + function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) { const requestIdentity = accountRefreshGuard.current.begin(); setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn); + if (activeSession?.sessionType === "birth_time_rectification" + && activeSession.rectificationCaseId !== turn.caseId) { + const boundSession = { + ...activeSession, + rectificationCaseId: turn.caseId, + updatedAt: timestamp(), + }; + updateSession(activeSession.id, () => boundSession); + void persistSession(boundSession).catch(() => { + setComposerNotice("校正进度已经保留,但会话关联暂时未同步到云端。"); + }); + } setAccount((current) => current ? { ...current, hasConfirmedBirthTime: current.hasConfirmedBirthTime @@ -2197,23 +2373,23 @@ export default function Home() { "unverified_birth_time", ) : birthTimeConsultationConsent; - const consultationRoute = resolveBirthTimeConsultationRoute( + const initialConsultationRoute = resolveBirthTimeConsultationRoute( profile, consentForDecision, sessionId, ); - if (consultationRoute.kind === "choice") { - setPendingBirthTimeChoice({ + if (initialConsultationRoute.kind === "choice") { + const generalConsent = grantBirthTimeConsultationConsent( + consentForDecision, sessionId, - question, - entrypoint, - theme, - }); - setComposerNotice(consultationRoute.canUseUnverifiedTime - ? "请选择在当前聊天临时使用填报时间,或先校正再询问。" - : "你还没有可使用的具体出生分钟,可以先校正,或改问不依赖出生分钟的一般问题。"); - return false; + "general_no_birth_time", + ); + setBirthTimeConsultationConsent(generalConsent); + setBirthTimeSoftNotice("出生时间尚未校正,本次将按不依赖具体分钟的一般咨询回答。"); } + const consultationRoute = initialConsultationRoute.kind === "choice" + ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } + : initialConsultationRoute; if (account.credits <= 0) { openAccountDialog("redeem", creditTrigger.current); @@ -2288,7 +2464,9 @@ export default function Home() { ].join("\n"), theme); const previewSession: ChatSession = { ...userSession, - title: currentSession.messages.length === 0 && previewReply.title ? previewReply.title : userSession.title, + title: currentSession.messages.length === 0 + ? resolveSessionTitle(question, previewReply.title) + : userSession.title, messages: [...userSession.messages, { role: "assistant", text: previewReply.text, @@ -2380,7 +2558,9 @@ export default function Home() { const completedSession: ChatSession = { ...userSession, - title: currentSession.messages.length === 0 && reply.title ? reply.title : userSession.title, + title: currentSession.messages.length === 0 + ? resolveSessionTitle(question, reply.title) + : userSession.title, messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions, techniqueTruth }], updatedAt: timestamp(), }; @@ -2498,9 +2678,13 @@ export default function Home() { return; } if (durableClaim.status === "consumed") { - setRectificationSurfaceOpen(false); + const returnSessionId = rectificationQuestionHandoff.current.peek()?.sessionId + ?? rectificationReturnSessionId; + if (returnSessionId && sessions.some((session) => session.id === returnSessionId)) { + activeSessionIdRef.current = returnSessionId; + setActiveSessionId(returnSessionId); + } setRectificationPendingQuestion(null); - setRectificationInitialTurn(null); setComposerNotice("原问题已经继续回答,不会再次发送或扣点。"); return; } @@ -2534,10 +2718,10 @@ export default function Home() { }, ); if (completed) { - setRectificationSurfaceOpen(false); setRectificationPendingQuestion(null); - setRectificationInitialTurn(null); - setComposerNotice("已使用新确认时间继续回答原问题。"); + setComposerNotice(confirmedTurn.candidate.status === "confirmed" + ? "已使用新确认时间继续回答原问题。" + : "已保留候选范围,并按未确认出生分钟的边界继续回答原问题。"); } else { setComposerNotice("原问题仍保留,可再次点击继续回答。"); } @@ -2549,104 +2733,21 @@ export default function Home() { } } - function restoreQuestionFromRectification() { - if (rectificationLoading || rectificationMutationPending - || rectificationContinuationInFlight.current) return; - const durableQuestion = rectificationInitialTurn?.pendingConsultationQuestion - ?? rectificationPendingQuestion - ?? rectificationQuestionHandoff.current.peek()?.question - ?? null; - let handoff = activeSession - ? rectificationQuestionHandoff.current.synchronizeDurableQuestion( - durableQuestion, - { sessionId: activeSession.id, theme: activeSession.theme }, - ) - : null; - const handoffSessionId = handoff?.sessionId ?? null; - if (handoffSessionId - && !sessions.some((session) => session.id === handoffSessionId) - && activeSession) { - rectificationQuestionHandoff.current.clear(); - handoff = rectificationQuestionHandoff.current.synchronizeDurableQuestion( - durableQuestion, - { sessionId: activeSession.id, theme: activeSession.theme }, - ); - } - if (handoff) { - activeSessionIdRef.current = handoff.sessionId; - setActiveSessionId(handoff.sessionId); - setDraft(handoff.question); - setDraftTheme(handoff.theme); - setDraftEntrypoint(null); - setComposerNotice("原问题已放回输入框;没有发起普通咨询,也未扣咨询点数。"); - rectificationQuestionHandoff.current.clear(); - window.requestAnimationFrame(() => composerInput.current?.focus()); - } - setRectificationSurfaceOpen(false); - setRectificationInitialTurn(null); - } + continueRectificationQuestion.current = (question) => { + void continueRectificationOriginalQuestion(question); + }; - function useUnverifiedTimeForPendingConsultation() { - if (!pendingBirthTimeChoice - || !activeSession - || pendingBirthTimeChoice.sessionId !== activeSession.id - || !canUseUnverifiedBirthTime(profile)) return; - const pending = pendingBirthTimeChoice; - setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent( - current, - activeSession.id, - "unverified_birth_time", - )); - setPendingBirthTimeChoice(null); - setComposerNotice("本次聊天会标明出生时间尚未校正;新聊天会重新提醒。"); - void send(pending.question, pending.theme, pending.entrypoint, "unverified_birth_time"); - } - - function continueGenerallyWithoutBirthTime() { - if (!pendingBirthTimeChoice - || !activeSession - || pendingBirthTimeChoice.sessionId !== activeSession.id - || canUseUnverifiedBirthTime(profile)) return; - const pending = pendingBirthTimeChoice; - setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent( - current, - activeSession.id, - "general_no_birth_time", - )); - setDraft(pending.question); - setDraftTheme("general"); - // Product entrypoints such as "今日星语" require a chart. General mode - // deliberately restores only visible text and never carries hidden routing. - setDraftEntrypoint(null); - setPendingBirthTimeChoice(null); - setComposerNotice("原问题尚未发送,也没有扣点。请把它改成不依赖个人出生分钟的一般问题后再发送。"); - window.requestAnimationFrame(() => composerInput.current?.focus()); - } - - function rectifyBeforePendingConsultation() { - if (!pendingBirthTimeChoice - || !activeSession - || pendingBirthTimeChoice.sessionId !== activeSession.id) return; - const pending = pendingBirthTimeChoice; - rectificationQuestionHandoff.current.capture({ - question: pending.question, - sessionId: pending.sessionId, - theme: pending.theme, - }); - setPendingBirthTimeChoice(null); - setComposerNotice(""); - void openBirthTimeRectification(pending.question); - } - - function cancelPendingBirthTimeChoice() { - if (!pendingBirthTimeChoice) return; - setDraft(pendingBirthTimeChoice.question); - setDraftTheme(pendingBirthTimeChoice.theme); - setDraftEntrypoint(pendingBirthTimeChoice.entrypoint); - setPendingBirthTimeChoice(null); - setComposerNotice(""); - window.requestAnimationFrame(() => composerInput.current?.focus()); - } + useEffect(() => { + const turn = rectificationInitialTurn; + const question = turn?.pendingConsultationQuestion; + if (!turn || turn.status !== "completed" || !question + || !turn.actions.includes("continue_original_question") + || rectificationLoading || rectificationMutationPending) return; + const continuationIdentity = `${turn.caseId}:${turn.turnVersion}:${question}`; + if (automaticRectificationContinuation.current === continuationIdentity) return; + automaticRectificationContinuation.current = continuationIdentity; + continueRectificationQuestion.current(question); + }, [rectificationInitialTurn, rectificationLoading, rectificationMutationPending]); function submit(event: FormEvent) { event.preventDefault(); @@ -2715,6 +2816,10 @@ export default function Home() { return (
+ {activeSession?.title || "新对话"} {isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") - : !profileComplete && onboardingStep === "rectification" + : rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification") ? "正在校正出生时间" : "基于星盘证据回答"} @@ -2789,9 +2894,9 @@ export default function Home() {
- {!activeSession?.messages.length ? ( + {!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
- {!profileComplete || onboardingJustCompleted ? ( + {!profileComplete ? ( <> {(onboardingStep !== "name" || profileComplete) && } @@ -2864,7 +2969,7 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} - {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !activeBirthTimeChoice && (onboardingPending ? ( + {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
正在准备三个入门问题…
) : (
@@ -2949,45 +3054,11 @@ export default function Home() { {activeError &&

{activeError}

}
- )} - {activeBirthTimeChoice && ( - - )} + ))} {rectificationSurfaceOpen && ( -
-
- {rectificationCardLabel} - {rectificationCardAction === "resume" - ? "继续同一账户案例,不重复收费" - : `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析生成后收取`} -
-
- -
+
{rectificationLoading ? ( -

正在恢复账户里的校正进度…

+ ) : rectificationError ? (

{rectificationError}

@@ -3000,9 +3071,11 @@ export default function Home() { 重试恢复
+ ) : !visibleRectificationTurn ? ( + ) : ( -
+ {!rectificationSurfaceOpen &&
{activeSuggestions.length > 0 && (
{activeSuggestions.map((question) => ( - + ))}
)} @@ -3037,7 +3110,7 @@ export default function Home() { : "例如:未来半年是否适合换工作?"} rows={1} maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} - disabled={isLoading || cancellationPending || Boolean(activeBirthTimeChoice) || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} + disabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} value={draft} onChange={(event) => { setDraft(event.target.value); @@ -3059,7 +3132,7 @@ export default function Home() {
-
+
} {activeAccountDialog !== null && ( diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index ed1358f7..eed98691 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -16,6 +16,23 @@ const confidenceLabels = { high: "较高置信", } as const; +const gateLabels: Readonly> = { + event_quality: "经历质量", + cross_domain_coverage: "跨领域覆盖", + required_layers: "必需计算层", + neighbor_stability: "相邻分钟稳定性", + leave_one_event_out: "删除单条经历复算", + three_engine_input_parity: "三引擎同输入对照", + public_holdout_release: "公开 AA 盲测", +}; + +const gateStatusLabels: Readonly> = { + pass: "通过", + fail: "未通过", + blocked: "尚未具备条件", + not_evaluated: "尚未执行", +}; + export function BirthTimeCandidateResult({ journey, controller, error }: CandidateResultProps) { const result = journey.candidateResult; const action = journey.nextAction; @@ -66,7 +83,14 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida

已用:{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}

辅助:{receipt.auxiliaryLayers.join("、") || "无"}

未完成:{receipt.missingLayers.join("、") || "无"}

- {receipt.hardBlockers.length > 0 &&

阻止确认:{receipt.hardBlockers.join("、")}

} + {receipt.gates && ( +
    + {Object.entries(receipt.gates).map(([name, gate]) => ( +
  • {gateLabels[name] ?? name}:{gateStatusLabels[gate.status] ?? gate.status}
  • + ))} +
+ )} + {receipt.confirmationAllowed === false &&

当前仅保留候选范围,分钟确认尚未开放。

} )} diff --git a/frontend/src/components/birth-time-life-events.tsx b/frontend/src/components/birth-time-life-events.tsx new file mode 100644 index 00000000..f9ea0dfb --- /dev/null +++ b/frontend/src/components/birth-time-life-events.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useRef, useState } from "react"; +import { lifeEventSchema } from "@/lib/birth-time-journey"; +import type { LifeEvent } from "@/lib/birth-time-evidence"; + +type EventDraft = { + readonly rowId: string; + readonly eventId: string; + readonly domain: LifeEvent["domain"] | ""; + readonly precision: LifeEvent["precision"]; + readonly date: string; +}; + +type BirthTimeLifeEventsProps = { + readonly initialEvents: readonly LifeEvent[]; + readonly pending: boolean; + readonly onSubmit: (events: readonly LifeEvent[]) => void; +}; + +const domainOptions = [ + { value: "education", label: "学业或学习方向" }, + { value: "relocation", label: "搬家、离乡或长期异地" }, + { value: "relationship", label: "重要关系节点" }, + { value: "career", label: "工作或身份变化" }, + { value: "health_pressure", label: "健康、事故或低谷" }, +] as const; + +const initialDomains = ["education", "career", "relationship"] as const; + +function initialDrafts(events: readonly LifeEvent[]): readonly EventDraft[] { + if (events.length > 0) { + return events.map((event, index) => ({ + rowId: `stored-${index}`, + eventId: event.id, + domain: event.domain, + precision: event.precision, + date: event.date, + })); + } + return initialDomains.map((domain, index) => ({ + rowId: `initial-${index}`, + eventId: "", + domain, + precision: "month", + date: "", + })); +} + +function dateInputType(precision: LifeEvent["precision"]) { + switch (precision) { + case "year": return "number"; + case "month": return "month"; + case "day": return "date"; + } +} + +function parseDomain(value: string): EventDraft["domain"] { + switch (value) { + case "": + case "education": + case "relocation": + case "relationship": + case "career": + case "health_pressure": + return value; + default: + return ""; + } +} + +function parsePrecision(value: string): EventDraft["precision"] { + switch (value) { + case "year": + case "month": + case "day": + return value; + default: + return "month"; + } +} + +export function BirthTimeLifeEvents({ + initialEvents, + pending, + onSubmit, +}: BirthTimeLifeEventsProps) { + const [drafts, setDrafts] = useState(() => initialDrafts(initialEvents)); + const [error, setError] = useState(""); + const nextRowId = useRef(0); + + function patchDraft(rowId: string, patch: Partial) { + setDrafts((current) => current.map((draft) => ( + draft.rowId === rowId ? { ...draft, ...patch } : draft + ))); + } + + function addDraft() { + setDrafts((current) => current.length >= 6 ? current : [...current, { + rowId: `added-${nextRowId.current++}`, + eventId: "", + domain: "", + precision: "month", + date: "", + }]); + } + + function removeDraft(rowId: string) { + setDrafts((current) => current.length <= 3 + ? current + : current.filter((draft) => draft.rowId !== rowId)); + } + + function submit() { + const parsed = drafts.map((draft) => lifeEventSchema.safeParse({ + id: draft.eventId || crypto.randomUUID(), + domain: draft.domain, + precision: draft.precision, + date: draft.date, + })); + if (parsed.some((item) => !item.success)) { + setError("请为每条经历选择类型,并填写与精度一致的日期。"); + return; + } + const events = parsed.flatMap((item) => item.success ? [item.data] : []); + if (new Set(events.map((event) => event.domain)).size < 2) { + setError("请至少选择两个不同领域的经历,以便区分候选时间。"); + return; + } + setError(""); + onSubmit(events); + } + + return ( +
+
+ 关键经历日期 + {drafts.length} / 6 +
+

+ 请填写至少三条、覆盖两个领域的经历。只使用日期和类型评分,不会从描述中猜测。 +

+
+ {drafts.map((draft, index) => ( +
+ 经历 {index + 1} + + + + {drafts.length > 3 && ( + + )} +
+ ))} +
+ {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/birth-time-soft-notice.tsx b/frontend/src/components/birth-time-soft-notice.tsx new file mode 100644 index 00000000..78c397ea --- /dev/null +++ b/frontend/src/components/birth-time-soft-notice.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { toast } from "sonner"; + +type BirthTimeSoftNoticeProps = Readonly<{ + message: string; + onDismiss: () => void; + durationMs?: number; +}>; + +export function BirthTimeSoftNotice({ + message, + onDismiss, + durationMs = 4_500, +}: BirthTimeSoftNoticeProps) { + useEffect(() => { + if (!message) return; + let toastId: string | number | undefined; + let timeout: number | undefined; + const frame = window.requestAnimationFrame(() => { + toastId = toast("出生时间尚未校正", { + description: message, + duration: durationMs, + }); + timeout = window.setTimeout(onDismiss, durationMs); + }); + return () => { + window.cancelAnimationFrame(frame); + if (timeout !== undefined) window.clearTimeout(timeout); + if (toastId !== undefined) toast.dismiss(toastId); + }; + }, [durationMs, message, onDismiss]); + + return null; +} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 54b69f68..f9e585b9 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState, - type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { ChatMessageContent } from "./chat-message-content.tsx"; import { @@ -20,6 +19,7 @@ type EvidenceDomain = NonNullable< const domainLabels = { career: "事业与身份", education: "学业与学习", + finance: "收入与资产", relocation: "搬迁与居住地", relationship: "重要关系", family: "家庭变化", @@ -42,6 +42,8 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification const confirmed = candidate.status === "confirmed" && turn.status === "completed"; const status = confirmed ? "已明确确认" + : turn.status === "completed" + ? "范围已保存 · 分钟未确认" : candidate.status === "ready_for_confirmation" ? "待确认 · 未验证" : "待验证 · 未确认"; @@ -71,94 +73,25 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification ); } -function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectificationTurn }) { - const receipt = turn.technicalReceipt; - return ( -
- 本轮技术回执 -
-
计算版本
{receipt.calculationVersion}
-
稳定层
{receipt.stableLayers.join("、") || "无"}
-
分钟敏感层
{receipt.sensitiveLayers.join("、") || "无"}
-
候选差异引用
{receipt.candidateDifferenceRefs.join("、") || "无"}
-
-
- ); -} - export function ConversationalRectificationSurface({ controller, pendingConsultationQuestion, continuationPending = false, onContinueOriginalQuestion, }: SurfaceProps) { - const [abandonArmedFor, setAbandonArmedFor] = useState(null); - const [localAnnouncement, setLocalAnnouncement] = useState | null>(null); const composer = useRef(null); - const abandonTrigger = useRef(null); - const abandonCancel = useRef(null); - const abandonConfirm = useRef(null); - const terminalStatus = useRef(null); - const restoreAbandonFocus = useRef(false); - const focusTerminalForCase = useRef(null); + const [eventYear, setEventYear] = useState(""); + const [eventMonth, setEventMonth] = useState(""); + const currentYear = new Date().getFullYear(); + const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index); const turn = controller.turn; const pendingQuestion = turn?.status === "completed" ? turn.pendingConsultationQuestion : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; - const abandonIdentity = turn - ? `${turn.caseId}:${turn.turnVersion}:${turn.status}` - : null; - const canAbandon = Boolean( - turn?.actions.includes("abandon") - && turn.status !== "abandoned" - && turn.status !== "completed", - ); - const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity; - const statusAnnouncement = localAnnouncement?.identity === abandonIdentity - ? localAnnouncement.message - : ""; - - useEffect(() => { - if (abandonArmed) { - abandonCancel.current?.focus(); - return; - } - if (restoreAbandonFocus.current) { - restoreAbandonFocus.current = false; - abandonTrigger.current?.focus(); - } - }, [abandonArmed]); - - useEffect(() => { - const requestedCase = focusTerminalForCase.current; - if (!requestedCase) return; - if (!turn || turn.caseId !== requestedCase) { - focusTerminalForCase.current = null; - return; - } - if (turn.status === "abandoned") { - focusTerminalForCase.current = null; - terminalStatus.current?.focus(); - } - }, [turn]); - if (!turn) { return (
-
-

系统会先说明候选边界,再邀请你提供已经发生的真实经历。

- -
+

正在建立校正记录…

{controller.error &&

{controller.error}

}
); @@ -166,46 +99,21 @@ export function ConversationalRectificationSurface({ const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; const requestedDomains = turn.evidenceRequest?.domains ?? []; - const submit = () => { - if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer()); + const submit = async () => { + if (!canAnswer || !controller.draft.trim() || controller.pending) return; + const dateLabel = eventYear + ? `${eventYear} 年${eventMonth ? ` ${Number(eventMonth)} 月` : ""}` + : "时间不确定"; + const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`; + try { + await controller.answer(undefined, answer); + setEventYear(""); + setEventMonth(""); + } catch { + // The controller owns the visible request error and keeps the draft available for retry. + } }; const focusComposer = () => composer.current?.focus(); - const continueLocally = () => { - if (abandonIdentity) { - setLocalAnnouncement({ - identity: abandonIdentity, - message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。", - }); - } - focusComposer(); - }; - const closeAbandonDialog = () => { - restoreAbandonFocus.current = true; - setAbandonArmedFor(null); - }; - const handleAbandonDialogKey = (event: ReactKeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - closeAbandonDialog(); - return; - } - if (event.key !== "Tab") return; - if (event.shiftKey && document.activeElement === abandonCancel.current) { - event.preventDefault(); - abandonConfirm.current?.focus(); - } else if (!event.shiftKey && document.activeElement === abandonConfirm.current) { - event.preventDefault(); - abandonCancel.current?.focus(); - } - }; - const confirmAbandon = () => { - focusTerminalForCase.current = turn.caseId; - void controller.abandon().catch(() => { - if (focusTerminalForCase.current === turn.caseId) { - focusTerminalForCase.current = null; - } - }); - }; return (
+ +
- +
+ +
{requestedDomains.length > 0 && (
- 这轮想先补充哪个领域? + 这轮想先补充哪个领域?
{requestedDomains.map((domain) => (