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:
@@ -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;
|
||||
|
||||
@@ -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 · 上升金牛座");
|
||||
});
|
||||
Reference in New Issue
Block a user