fix(rectification): show natal houses and lagna changes in a live right rail

The board was live data sitting under chat, so each new event looked like nothing changed. Keep candidate cards in the transcript and group 换升 by minute for the side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 14:24:05 +08:00
parent 19de0bab36
commit e07d1bada4
10 changed files with 985 additions and 130 deletions
+222 -3
View File
@@ -474,6 +474,7 @@ button:disabled { cursor: default; opacity: .45; }
}
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 68px minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: 68px minmax(0, 1fr); }
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
.chat-header > div { min-width: 0; }
.chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; }
@@ -1069,6 +1070,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
[data-sidebar="rail"] { display: none; }
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-header { gap: 10px; padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
@@ -1122,6 +1124,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.paywall-footer > a, .paywall-footer > button { width: 100%; }
.dialog-actions { flex-direction: column-reverse; }
.dialog-actions > button { width: 100%; }
dialog.rectification-board {
width: 100%;
height: min(86dvh, 100%);
margin-block-start: auto;
margin-inline: 0;
border-inline-start: 0;
border-block-start: 1px solid var(--color-border);
border-start-start-radius: var(--radius-lg);
border-start-end-radius: var(--radius-lg);
padding-bottom: env(safe-area-inset-bottom);
}
.auth-story { min-height: 0; padding: var(--space-5); }
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
@@ -1847,12 +1860,218 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
.rectification-workspace {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
background: var(--color-canvas);
}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
}
.rectification-workspace__chat {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.rectification-workspace__chat .conversation {
padding-bottom: var(--space-4);
}
.rectification-workspace__chat .message-list {
width: 100%;
max-width: 720px;
}
.rectification-workspace__chat .composer-wrap {
position: relative;
z-index: 2;
}
.rectification-board {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-inline-start: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
dialog.rectification-board {
width: min(22.5rem, 100%);
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
margin-inline-start: auto;
padding: 0;
border: 0;
border-inline-start: 1px solid var(--color-border);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
dialog.rectification-board::backdrop {
background: var(--color-scrim);
}
.rectification-board__header {
position: sticky;
top: 0;
z-index: 1;
display: grid;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) var(--space-3);
background: var(--color-canvas-soft);
}
.rectification-board__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
}
.rectification-board__header h2 {
margin: 0;
font-family: var(--font-display);
font-size: var(--type-title-sm);
font-weight: 600;
letter-spacing: -.2px;
}
.rectification-board__clock {
color: var(--color-ink);
font-variant-numeric: tabular-nums;
font-size: var(--type-body-sm);
font-weight: 600;
}
.rectification-board__close {
min-height: 44px;
margin-inline-start: auto;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
cursor: pointer;
}
.rectification-board__close:focus-visible,
.rectification-board-peek:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-board__stage,
.rectification-board__empty,
.rectification-board__disclosure {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.rectification-board__body {
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-6);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--space-4) var(--space-6);
}
.rectification-board .rectification-snapshot {
display: grid;
gap: var(--space-4);
width: auto;
margin: 0;
}
.rectification-board .rectification-house-table {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.rectification-board .technique-audit {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.rectification-board__section {
display: grid;
gap: var(--space-3);
}
.rectification-board__section h3 {
margin: 0;
font-family: var(--font-display);
font-size: var(--type-title-sm);
font-weight: 600;
letter-spacing: -.2px;
}
.rectification-board__minutes {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.rectification-board__minute {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
}
.rectification-board__minute time {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rectification-board__minute-note {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-board__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1 1 100%;
margin: 0;
padding: 0;
list-style: none;
}
.rectification-board__chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
font-size: var(--type-caption);
}
.rectification-board-peek {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
min-height: 44px;
margin: 0 0 var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink);
text-align: start;
cursor: pointer;
}
.rectification-board-peek strong { font-size: var(--type-body-sm); }
.rectification-board-peek span {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-house-table__changed {
display: inline;
margin-inline-start: .4em;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-snapshot {
display: grid;
gap: var(--space-3);
width: calc(100% - var(--assistant-content-inset));
margin: 8px 0 16px;
margin-inline-start: var(--assistant-content-inset);
}
.rectification-candidates {
display: grid;
+1 -1
View File
@@ -3552,7 +3552,7 @@ export default function Home() {
</section>
</div>
) : null}
<SidebarInset className="chat-panel" inert={modalOpen}>
<SidebarInset className={`chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`} inert={modalOpen}>
<header className="chat-header">
<SidebarTrigger placement="inset" />
<div>
@@ -1,7 +1,7 @@
"use client";
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
@@ -17,6 +17,10 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
@@ -30,8 +34,7 @@ import {
toggleChatMessageFeedback,
} from "./chat-message-actions";
import { ModelSelector } from "./model-selector";
import { RectificationHouseTableView } from "./rectification-house-table";
import { TechniqueAuditDisclosure, ConfirmationGateDisclosure } from "./technique-audit-disclosure";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -104,75 +107,6 @@ function RectificationCandidateCards({
);
}
function RectificationRefinementPanel({
result,
}: Readonly<{ result: RectificationCandidateResult }>) {
const transitions = result.windowTransitions;
const ledger = result.eventDashaLedger;
const agreement = result.dashaAgreement;
const contrast = result.lagnaContrast;
const nakshatra = result.nakshatraBoundary;
const stage = result.precisionStage;
if (
transitions.length === 0
&& ledger.length === 0
&& !agreement
&& !contrast
&& !nakshatra?.near_boundary
&& !stage
) return null;
return (
<section className="rectification-refinement" aria-label="校正收窄观察">
{stage && (
<p className="rectification-refinement__stage">{stage.user_meaning}</p>
)}
{transitions.length > 0 && (
<div>
<strong></strong>
<ul>
{transitions.map((item) => (
<li key={`${item.layer}-${item.at}`}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{contrast && (
<div>
<strong></strong>
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
)}
{ledger.length > 0 && (
<div>
<strong></strong>
<ul>
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{agreement && (
<p>{agreement.user_meaning}</p>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<div>
<strong></strong>
<p>{nakshatra.user_meaning}</p>
</div>
)}
</section>
);
}
function RectificationOosPanel({
prompts,
}: Readonly<{ prompts: RectificationCandidateResult["oosBlindPrompts"] }>) {
@@ -319,9 +253,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
useLayoutEffect(() => {
const node = workspace.current;
if (!node) return;
const update = () => {
const nextCompact = node.clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
@@ -684,9 +646,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const canSend = !busy && !readonly && !regeneratingMessageKey;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list" aria-live="polite">
<div
ref={workspace}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
{pendingConsultationQuestion.trim()}
@@ -746,19 +712,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{candidateResult?.houseTable && (
<div className="rectification-snapshot">
{candidateResult.natalRecast && (
<p className="rectification-refinement__stage">{candidateResult.natalRecast.user_meaning}</p>
)}
<RectificationHouseTableView table={candidateResult.houseTable} />
<ConfirmationGateDisclosure gate={candidateResult.confirmationGate} />
{savedStatus === "accepted" && candidateResult.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={candidateResult.techniqueAudit} />
)}
</div>
)}
{candidateResult && <RectificationRefinementPanel result={candidateResult} />}
{savedTime && (
<div className="rectification-saved-wrap">
<p className="rectification-saved" role="status">
@@ -786,8 +739,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
</section>
<div className="composer-wrap">
<form className="composer" onSubmit={submit}>
<div className="composer-wrap">
{compactBoard && (
<RectificationBoardPeek
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={() => setBoardOpen(true)}
/>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
@@ -817,7 +778,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onSelect={onSelectModel}
/>
</div>
</div>
</div>
</>
<RectificationBoard
result={candidateResult}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useRef } from "react";
import {
groupWindowTransitions,
rectificationBoardPeekCopy,
workingRectificationTime,
type RectificationBoardDiff,
} from "@/lib/rectification-board-model";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
windowScanLayerLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { RectificationHouseTableView } from "./rectification-house-table";
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
if (layers.length === 0) return null;
return (
<ul className="rectification-board__chips">
{layers.map((layer) => (
<li key={layer}>
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
</li>
))}
</ul>
);
}
function RectificationBoardBody({
result,
savedStatus,
diff,
compact,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
titleId: string;
onClose: () => void;
}>) {
const table = result ? workingRectificationHouseTable(result) : null;
const workingTime = workingRectificationTime(result);
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
const changedHouses = new Set(diff.houseNumbers);
const changedMinutes = new Set(diff.transitionMinutes);
const stage = result?.precisionStage?.user_meaning
?? result?.natalRecast?.user_meaning
?? null;
const ledger = result?.eventDashaLedger ?? [];
const contrast = result?.lagnaContrast ?? null;
const agreement = result?.dashaAgreement ?? null;
const nakshatra = result?.nakshatraBoundary ?? null;
return (
<>
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
{compact && (
<button
className="rectification-board__close"
type="button"
onClick={onClose}
>
</button>
)}
</div>
{stage ? <p className="rectification-board__stage">{stage}</p> : null}
</header>
<div className="rectification-board__body">
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
{!table && (
<p className="rectification-board__empty">
</p>
)}
{table && (
<div className="rectification-snapshot">
<RectificationHouseTableView
table={table}
changedHouses={changedHouses}
lagnaChanged={diff.lagnaChanged}
/>
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
)}
</div>
)}
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
<section className="rectification-board__section" aria-label="换升时刻">
<h3></h3>
{scoringMinutes.length > 0 && (
<ol className="rectification-board__minutes">
{scoringMinutes.map((row) => {
const changed = changedMinutes.has(row.at);
const working = row.at === workingTime;
return (
<li
key={row.at}
className={[
"rectification-board__minute",
changed ? "is-changed" : "",
working ? "is-working" : "",
].filter(Boolean).join(" ")}
>
<time dateTime={row.at}>{row.at}</time>
{working ? <span className="rectification-board__minute-note"></span> : null}
{changed ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.scoringLayers} />
</li>
);
})}
</ol>
)}
{displayMinutes.length > 0 && (
<details className="technique-audit">
<summary>
<small>{displayMinutes.length} </small>
</summary>
<ol className="rectification-board__minutes">
{displayMinutes.map((row) => (
<li
key={`display-${row.at}`}
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
>
<time dateTime={row.at}>{row.at}</time>
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.displayLayers} />
</li>
))}
</ol>
</details>
)}
</section>
)}
{contrast && (
<details className="technique-audit">
<summary></summary>
<div className="rectification-board__disclosure">
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
</details>
)}
{ledger.length > 0 && (
<details className="technique-audit">
<summary>
<small>{ledger.length} </small>
</summary>
<ul className="rectification-board__disclosure">
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
{agreement ? <p className="rectification-board__disclosure">{agreement.user_meaning}</p> : null}
</details>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<details className="technique-audit">
<summary></summary>
<p className="rectification-board__disclosure">{nakshatra.user_meaning}</p>
</details>
)}
</div>
</>
);
}
export function RectificationBoard({
result,
savedStatus,
diff,
compact,
open,
boardId,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
open: boolean;
boardId: string;
titleId: string;
onClose: () => void;
}>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const body = (
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
);
useEffect(() => {
const node = dialogRef.current;
if (!compact || !node) return;
if (open) {
if (!node.open) node.showModal();
} else if (node.open) {
node.close();
}
}, [compact, open]);
useEffect(() => {
if (!compact) return;
const node = dialogRef.current;
if (!node) return;
const handleClose = () => onCloseRef.current();
node.addEventListener("close", handleClose);
return () => node.removeEventListener("close", handleClose);
}, [compact]);
if (compact) {
return (
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</dialog>
);
}
return (
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</aside>
);
}
export function RectificationBoardPeek({
result,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result);
return (
<button
className="rectification-board-peek"
type="button"
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
</button>
);
}
@@ -4,13 +4,21 @@ import type { RectificationHouseTable } from "@/lib/rectification-candidate-resu
export function RectificationHouseTableView({
table,
}: Readonly<{ table: RectificationHouseTable }>) {
changedHouses,
lagnaChanged,
}: Readonly<{
table: RectificationHouseTable;
changedHouses?: ReadonlySet<number>;
lagnaChanged?: boolean;
}>) {
const changed = changedHouses ?? new Set<number>();
return (
<section className="rectification-house-table" aria-label="当前本命宫位" aria-live="polite">
<section className="rectification-house-table" aria-label="当前本命宫位">
<div className="rectification-house-table__heading">
<strong></strong>
<span>
{table.time} {table.lagna}线
{table.time} {table.lagna}
{lagnaChanged ? "(已更新)" : ""}线
</span>
<span></span>
</div>
@@ -24,13 +32,19 @@ export function RectificationHouseTableView({
</tr>
</thead>
<tbody>
{table.houses.map((house) => (
<tr key={house.house}>
<th scope="row">{house.house}</th>
<td>{house.sign}</td>
<td>{house.occupants.length > 0 ? house.occupants.join("、") : "—"}</td>
</tr>
))}
{table.houses.map((house) => {
const houseChanged = changed.has(house.house);
return (
<tr key={house.house} data-changed={houseChanged ? "true" : undefined}>
<th scope="row">{house.house}</th>
<td>
{house.sign}
{houseChanged ? <span className="rectification-house-table__changed"></span> : null}
</td>
<td>{house.occupants.length > 0 ? house.occupants.join("、") : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -27,6 +27,36 @@ const LAYER_LABEL: Record<WindowScanLayer, string> = {
pranapada: "Pranapada Lagna",
};
export const WINDOW_SCAN_SCORING_LAYER_ORDER = [
"d1",
"d9",
"d10",
"d4",
"d5",
"d7",
"d12",
"d24",
"d2",
"d11",
"d30",
] as const satisfies readonly WindowScanLayer[];
export const WINDOW_SCAN_DISPLAY_LAYER_ORDER = [
"pada",
"hora",
"ghati",
"bhava",
"pranapada",
] as const satisfies readonly WindowScanLayer[];
export function windowScanLayerLabel(layer: WindowScanLayer): string {
return LAYER_LABEL[layer];
}
export function isWindowScanDisplayLayer(layer: WindowScanLayer): boolean {
return (WINDOW_SCAN_DISPLAY_LAYER_ORDER as readonly WindowScanLayer[]).includes(layer);
}
export type WindowScanLayer =
| "d1"
| "d9"
@@ -0,0 +1,177 @@
import {
isWindowScanDisplayLayer,
WINDOW_SCAN_DISPLAY_LAYER_ORDER,
WINDOW_SCAN_SCORING_LAYER_ORDER,
type WindowScanLayer,
type WindowScanTransition,
} from "./rectification-agentic/v9/refinement-packet";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
type RectificationHouseTable,
} from "./rectification-candidate-result";
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 832;
export type GroupedWindowMinute = Readonly<{
at: string;
scoringLayers: readonly WindowScanLayer[];
displayLayers: readonly WindowScanLayer[];
}>;
export type RectificationBoardDiff = Readonly<{
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
liveMessage: string;
}>;
const SCORING_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_SCORING_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const DISPLAY_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_DISPLAY_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const EMPTY_DIFF: RectificationBoardDiff = {
houseNumbers: [],
lagnaChanged: false,
timeChanged: false,
transitionMinutes: [],
liveMessage: "",
};
function sortLayers(
layers: readonly WindowScanLayer[],
rank: ReadonlyMap<WindowScanLayer, number>,
): readonly WindowScanLayer[] {
return [...layers].sort((left, right) => (rank.get(left) ?? 99) - (rank.get(right) ?? 99));
}
export function groupWindowTransitions(
transitions: readonly WindowScanTransition[],
): readonly GroupedWindowMinute[] {
const byMinute = new Map<string, { scoring: Set<WindowScanLayer>; display: Set<WindowScanLayer> }>();
for (const item of transitions) {
const bucket = byMinute.get(item.at) ?? { scoring: new Set<WindowScanLayer>(), display: new Set<WindowScanLayer>() };
if (isWindowScanDisplayLayer(item.layer)) bucket.display.add(item.layer);
else bucket.scoring.add(item.layer);
byMinute.set(item.at, bucket);
}
return [...byMinute.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([at, bucket]) => ({
at,
scoringLayers: sortLayers([...bucket.scoring], SCORING_RANK),
displayLayers: sortLayers([...bucket.display], DISPLAY_RANK),
}));
}
export function changedHouseNumbers(
previous: RectificationHouseTable | null,
next: RectificationHouseTable | null,
): readonly number[] {
if (!previous || !next) return [];
const changed: number[] = [];
for (let index = 0; index < 12; index += 1) {
const before = previous.houses[index];
const after = next.houses[index];
if (!before || !after) continue;
if (before.sign !== after.sign || before.occupants.join("\0") !== after.occupants.join("\0")) {
changed.push(after.house);
}
}
return changed;
}
function groupedMinuteKey(row: GroupedWindowMinute): string {
return `${row.at}|${row.scoringLayers.join(",")}|${row.displayLayers.join(",")}`;
}
export function changedTransitionMinutes(
previous: readonly WindowScanTransition[],
next: readonly WindowScanTransition[],
): readonly string[] {
const before = new Map(groupWindowTransitions(previous).map((row) => [row.at, groupedMinuteKey(row)]));
const changed: string[] = [];
for (const row of groupWindowTransitions(next)) {
if (before.get(row.at) !== groupedMinuteKey(row)) changed.push(row.at);
}
return changed;
}
function joinHouses(houses: readonly number[]): string {
return houses.map((house) => `${house}`).join("、");
}
export function rectificationBoardLiveMessage(input: Readonly<{
previous: RectificationCandidateResult | null;
next: RectificationCandidateResult | null;
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
}>): string {
const nextTable = input.next ? workingRectificationHouseTable(input.next) : null;
if (!nextTable) return "";
if (!input.previous) {
return `当前本命宫位已按 ${nextTable.time} 排出,上升${nextTable.lagna}`;
}
const parts: string[] = [];
if (input.timeChanged || input.lagnaChanged) {
parts.push(`本命宫位已按 ${nextTable.time} 重算(上升 ${nextTable.lagna})。`);
}
if (input.houseNumbers.length > 0 && !(input.timeChanged && input.houseNumbers.length === 12)) {
parts.push(`${joinHouses(input.houseNumbers)}已更新。`);
}
if (input.transitionMinutes.length > 0) {
parts.push("换升时刻已更新。");
}
return parts.join("");
}
export function diffRectificationBoard(
previous: RectificationCandidateResult | null,
next: RectificationCandidateResult | null,
): RectificationBoardDiff {
if (!next) return EMPTY_DIFF;
const previousTable = previous ? workingRectificationHouseTable(previous) : null;
const nextTable = workingRectificationHouseTable(next);
const houseNumbers = changedHouseNumbers(previousTable, nextTable);
const lagnaChanged = Boolean(previousTable && nextTable && previousTable.lagna !== nextTable.lagna);
const timeChanged = Boolean(previousTable && nextTable && previousTable.time !== nextTable.time);
const transitionMinutes = previous
? changedTransitionMinutes(previous.windowTransitions, next.windowTransitions)
: [];
return {
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
liveMessage: rectificationBoardLiveMessage({
previous,
next,
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
}),
};
}
export function rectificationBoardPeekCopy(result: RectificationCandidateResult | null): Readonly<{
title: string;
detail: string | null;
}> {
const table = result ? workingRectificationHouseTable(result) : null;
if (!table) {
return { title: "当前盘面", detail: "补充经历后会在这里更新" };
}
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
}
export function workingRectificationTime(result: RectificationCandidateResult | null): string | null {
if (!result) return null;
return result.selectedTime ?? result.representativeTime ?? workingRectificationHouseTable(result)?.time ?? null;
}
@@ -24,6 +24,14 @@ const agent = readFileSync(
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const board = readFileSync(
new URL("../src/components/rectification-board.tsx", import.meta.url),
"utf8",
);
const houseTable = readFileSync(
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
"utf8",
);
const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
@@ -224,7 +232,7 @@ test("rectification uses one case-level entitlement and the session-pinned model
test("Agentic rectification scrolls the conversation container as streamed messages grow", () => {
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section ref=\{conversation\} className="conversation"/);
assert.match(chat, /<section ref=\{conversation\} className="conversation is-rectification"/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: container\.scrollHeight/);
assert.match(chat, /\}, \[busy, candidateResult, error, messages, savedTime\]\);/);
@@ -245,7 +253,7 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
assert.match(chat, /vargaSentenceFromMethods/);
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
assert.match(chat, /RectificationHouseTableView/);
assert.match(board, /RectificationHouseTableView/);
assert.doesNotMatch(chat, /activeActivity/);
assert.doesNotMatch(chat, /正在读取校正记录/);
assert.doesNotMatch(chat, /<CompletedActivityReceipt/);
@@ -259,9 +267,8 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
chat.indexOf("{savedTime &&"),
);
const replyIndex = messageRender.indexOf("<ChatMessageRow");
const houseIndex = messageRender.indexOf("<RectificationHouseTableView");
assert.ok(replyIndex >= 0);
assert.ok(houseIndex > replyIndex);
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
assert.match(completedActivityReceipt, /<details className=/);
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
@@ -314,34 +321,33 @@ test("candidate state renders from the snapshot API and never from sentinels", (
assert.match(chat, /isRecommendedRectificationCandidate/);
assert.match(chat, /已采用/);
assert.match(chat, /正在采用…/);
assert.match(chat, /RectificationHouseTableView/);
assert.match(chat, /className="rectification-snapshot"/);
assert.match(chat, /RectificationBoard/);
assert.match(board, /className="rectification-snapshot"/);
assert.match(styles, /\.rectification-snapshot \{/);
assert.match(styles, /\.rectification-house-table \{[^}]*border:\s*1px solid var\(--color-border\)/);
assert.match(styles, /\.rectification-house-table \{[^}]*background:\s*var\(--color-canvas-soft\)/);
assert.match(styles, /\.message-list \{[\s\S]*--assistant-content-inset: calc\(32px \+ var\(--space-3\)\)/);
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-snapshot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-snapshot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-workspace \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(18rem, 22\.5rem\)/);
assert.match(styles, /\.rectification-workspace\.is-compact \{[\s\S]*grid-template-columns: minmax\(0, 1fr\)/);
assert.doesNotMatch(styles, /\.rectification-snapshot \{[^}]*--assistant-content-inset/);
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*padding-inline-start:\s*var\(--assistant-content-inset\)/);
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*--assistant-content-inset/);
assert.doesNotMatch(page, /rectification-workspace/);
assert.match(page, /chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}/);
});
test("the natal house table is a live snapshot card outside the agent bubble", () => {
const houseTable = readFileSync(
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
"utf8",
);
test("the natal house table is a live board beside the chat, not a message", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.houseTable &&"),
);
const snapshot = chat.slice(
chat.indexOf("{candidateResult?.houseTable &&"),
chat.indexOf("{savedTime &&"),
);
const snapshot = board.slice(
board.indexOf("className=\"rectification-snapshot\""),
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
);
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
@@ -349,8 +355,15 @@ test("the natal house table is a live snapshot card outside the agent bubble", (
assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex);
assert.doesNotMatch(snapshot, /<RectificationCandidateCards/);
assert.doesNotMatch(snapshot, /当前可能的出生时间/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
assert.doesNotMatch(messageLoop, /<RectificationHouseTableView/);
assert.match(chat, /className="conversation is-rectification"/);
assert.match(chat, /<RectificationBoard/);
assert.match(chat, /await loadCandidate\(\)\.then\(\(result\) => \{/);
assert.match(houseTable, /aria-live="polite"/);
assert.match(board, /aria-live="polite"/);
assert.match(board, /groupWindowTransitions/);
assert.match(board, /<dialog/);
assert.match(chat, /RectificationBoardPeek/);
assert.match(houseTable, /当前本命宫位/);
assert.match(houseTable, /补充经历后会按新线索重算/);
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
@@ -359,7 +372,7 @@ test("the natal house table is a live snapshot card outside the agent bubble", (
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.houseTable &&"),
chat.indexOf("{savedTime &&"),
);
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
@@ -437,8 +450,8 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
test("adopted time offers a consultation handoff without unique-minute copy", () => {
assert.match(chat, /用这个时间看盘/);
assert.match(chat, /onStartConsultation/);
assert.match(chat, /换升时刻/);
assert.match(chat, /经历与大运对照/);
assert.match(board, /换升时刻/);
assert.match(board, /经历与大运对照/);
assert.match(chat, /盘外对照/);
assert.match(page, /startConsultationAfterRectification/);
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedHouseNumbers,
diffRectificationBoard,
groupWindowTransitions,
rectificationBoardPeekCopy,
} from "../src/lib/rectification-board-model.ts";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import type { WindowScanTransition } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881";
const SECOND_CANDIDATE_ID = "88888888-8888-4888-8888-888888888882";
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
function transition(layer: WindowScanTransition["layer"], at: string): WindowScanTransition {
return { layer, at, user_meaning: `${layer}${at} 发生变化` };
}
function houses(lagna: string, seventh = lagna) {
return Array.from({ length: 12 }, (_, index) => ({
house: index + 1,
sign: index === 6 ? seventh : lagna,
occupants: index === 0 ? ["太阳"] : [],
}));
}
function snapshot(input: Readonly<{
time: string;
lagna: string;
seventh?: string;
transitions?: readonly WindowScanTransition[];
}>) {
return parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: input.time, relativeSupport: 34, tiedMinuteCount: 1 },
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "05:08", relativeSupport: 33, tiedMinuteCount: 1 },
{ candidateId: THIRD_CANDIDATE_ID, rank: 3, time: "05:09", relativeSupport: 33, tiedMinuteCount: 1 },
],
overallConfidence: "low",
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: input.time,
selectedTime: null,
selectionKind: null,
decisionReceipt: {
house_table: {
time: input.time,
lagna: input.lagna,
houses: houses(input.lagna, input.seventh ?? input.lagna),
},
window_scan: {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
transitions: input.transitions ?? [],
},
},
});
}
test("groups 换升时刻 by clock minute and splits scoring layers from display layers", () => {
const grouped = groupWindowTransitions([
transition("d11", "04:47"),
transition("hora", "04:47"),
transition("d24", "04:47"),
transition("d10", "04:48"),
transition("pranapada", "05:14"),
transition("d9", "04:57"),
]);
assert.deepEqual(grouped.map((row) => row.at), ["04:47", "04:48", "04:57", "05:14"]);
assert.deepEqual(grouped[0], {
at: "04:47",
scoringLayers: ["d24", "d11"],
displayLayers: ["hora"],
});
assert.deepEqual(grouped[1]?.scoringLayers, ["d10"]);
assert.deepEqual(grouped[3], {
at: "05:14",
scoringLayers: [],
displayLayers: ["pranapada"],
});
});
test("marks only houses whose sign or occupants actually changed", () => {
const before = snapshot({ time: "04:45", lagna: "金牛座" })?.houseTable ?? null;
const after = snapshot({ time: "04:48", lagna: "金牛座", seventh: "天秤座" })?.houseTable ?? null;
assert.deepEqual(changedHouseNumbers(before, after), [7]);
assert.deepEqual(changedHouseNumbers(null, after), []);
});
test("announces the first board fill and later house or 换升 changes without dumping the table", () => {
const first = snapshot({
time: "04:45",
lagna: "金牛座",
transitions: [transition("d9", "04:57")],
});
const second = snapshot({
time: "04:48",
lagna: "金牛座",
seventh: "天秤座",
transitions: [transition("d9", "04:57"), transition("d10", "04:48")],
});
assert.ok(first && second);
const opened = diffRectificationBoard(null, first);
assert.equal(opened.liveMessage, "当前本命宫位已按 04:45 排出,上升金牛座。");
assert.deepEqual(opened.houseNumbers, []);
assert.equal(opened.timeChanged, false);
const updated = diffRectificationBoard(first, second);
assert.equal(updated.timeChanged, true);
assert.deepEqual(updated.houseNumbers, [7]);
assert.deepEqual(updated.transitionMinutes, ["04:48"]);
assert.match(updated.liveMessage, /本命宫位已按 04:48 重算(上升 金牛座)。/);
assert.match(updated.liveMessage, /第7宫已更新/);
assert.match(updated.liveMessage, /换升时刻已更新/);
assert.doesNotMatch(updated.liveMessage, /太阳|第1宫|第2宫|下面是本轮/);
});
test("peek copy stays a control label, not a second dump of the house table", () => {
const empty = rectificationBoardPeekCopy(null);
assert.equal(empty.title, "当前盘面");
assert.equal(empty.detail, "补充经历后会在这里更新");
const filled = rectificationBoardPeekCopy(snapshot({ time: "04:48", lagna: "金牛座" }));
assert.equal(filled.title, "当前盘面");
assert.equal(filled.detail, "04:48 · 上升金牛座");
});