Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_ChenandClaude Opus 5 ca0204984b
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。

T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部
已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下
也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面
是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条
高这条正确性约束),而是在既有条上补齐缺的两项:

  05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题

代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个
值两处用,不是两条推导路径。已答题数逐字读服务端的
inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。
条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高
在滚动容器外,变高会让贴底读者的内容无声滑出视野。

收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白
正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572),
拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。

T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块),
按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、
本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有
渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed /
当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。

顺带修掉一个静默失效的断言:rectification-agentic-entry 用
board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1)
会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的
sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。

tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败
仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static;
产物 CSS gzip 39,017 → 38,973 B(−0.11%)。

窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境
缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:44:49 +00:00

1864 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
completeActivityTrace,
completeActivityTraceStep,
emptyActivityTrace,
freezeLiveThink,
startActivityTraceStep,
type AgentActivityTraceItem,
} from "@/lib/agent-activity-trace";
import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_ANALYZING_LIVE_LABEL,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationLiveProgressLabel,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
natalRecastMeaning,
parseRectificationCandidateResult,
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
workingRectificationTime,
} from "@/lib/rectification-board-model";
import {
rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
RECTIFICATION_QUESTION_REPAIR_LIMIT,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_DELIVERED_COPY,
isAbortError,
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
rectificationReadonlyRangeCopy,
contrastProbesFromReceipt,
searchWindowFromSnapshot,
caseStageFromSnapshot,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
birthTimeSourceFromSnapshot,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { RectificationTimeline } from "@/components/rectification-timeline";
import {
buildRectificationTimeline,
type RectificationTimelineStage,
} from "@/lib/rectification-timeline-scale";
import {
isPublicRectificationActivity,
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { ChatComposer } from "./chat-composer";
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
import { JumpToLatestButton } from "./jump-to-latest-button";
import { toggleChatMessageFeedback } from "./chat-message-actions";
import {
RectificationMessageEntry,
type RectificationMessageActions,
type RenderMessage,
} from "./rectification-message-entry";
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import {
applyLiveCandidateOffer,
copyTextForMessage,
interviewQuestionBlocksAdoptOffer,
nextSelectionCardLock,
parseTurnQuestion,
questionIsAnswered,
resolveSelectionCardMessageKey,
type SelectionCardLock,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
mergeTurnQuestions,
} from "@/lib/rectification-snapshot-messages";
type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
}>;
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
type CandidateResult = RectificationCandidateResult | null;
type CurrentQuestionModel = Readonly<{
kind: "choice" | "collect_spoken";
prompt: string | null;
focus_id: string | null;
question_id: string | null;
}>;
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") {
return null;
}
return {
kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice",
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
focus_id: typeof question.focus_id === "string" ? question.focus_id : null,
question_id: typeof question.question_id === "string" ? question.question_id : null,
};
}
function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null {
if (value === "unavailable") return "unavailable";
if (value === "focus") return "focus";
return null;
}
function RectificationReadonlyRange({
range,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
}: Readonly<{
range: readonly [string, string];
sessionOutcome: string | null;
discriminatingEventProbes: unknown;
contrastProbes: unknown;
}>) {
return (
<p className="rectification-readonly-range" role="status">
{rectificationReadonlyRangeCopy({
range,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
})}
</p>
);
}
type RectificationAgenticChatProps = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, for the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
}>;
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message;
return {
...message,
question: {
...question,
status: "resolved",
answer_option: selected === "typed" ? null : selected,
},
};
});
}
function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] {
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
let state = createRectificationActivityReceiptState();
for (const activity of receipt.tool_activities) {
if (!isPublicRectificationTool(activity.tool)
|| (activity.status !== "completed" && activity.status !== "failed")) continue;
state = reduceRectificationActivityReceipt(state, {
tool: activity.tool,
status: activity.status,
methods: activity.status === "completed" && Array.isArray(activity.methods)
? activity.methods.filter(isPublicRectificationMethod)
: [],
});
}
return receiptFromRectificationActivityState(state);
}
return {
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
};
}
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return toggleChatMessageFeedback(current, requested);
}
function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean {
return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool);
}
function persistedTurnFailed(turn: PersistedTurn): boolean {
return turn.status === "failed"
|| turn.status === "retryable"
|| turn.receipt?.status === "failed"
|| turn.receipt?.status === "degraded"
|| turn.receipt?.status === "blocked";
}
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
const failed = persistedTurnFailed(turn);
const raw = failed ? "" : turn.text ?? "";
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: raw,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt,
activityTrace: activityTraceFromReceipt(completedReceipt),
failed,
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
candidateOffer: typeof turn.offer_result_id === "string"
? { resultId: turn.offer_result_id }
: undefined,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
if (isStructuredChoiceUserText(turn.text)) return [];
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
type CaseSnapshotState = Readonly<{
candidate: CandidateResult;
question: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choice: ChoiceCardModel | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
nextUserActionId: string | null;
searchWindow: readonly [string, string] | null;
stage: RectificationTimelineStage | null;
interviewStopReason: string | null;
interviewSessionOutcome: string | null;
birthTimeSource: ReturnType<typeof birthTimeSourceFromSnapshot>;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
const id = payload?.next_user_action?.id;
return typeof id === "string" && id.trim() ? id.trim() : null;
}
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
return {
candidate: parseRectificationCandidateResult(payload.latest_result),
question: currentQuestionFromSnapshot(payload.current_question),
questionSource: questionSourceFromSnapshot(payload.question_source),
choice: parseRectificationChoiceCard(payload.choice_card),
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
nextUserActionId: nextUserActionIdFromSnapshot(payload),
searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range),
stage: caseStageFromSnapshot(payload.case?.stage),
interviewStopReason: interviewStopReasonFromSnapshot(payload),
interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload),
birthTimeSource: birthTimeSourceFromSnapshot(payload),
};
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
readonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
declaredTime,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
onPendingChange,
onProfileIncomplete,
onOpenBilling,
onSaved,
onOpeningConsumed,
pendingConsultationQuestion,
onRestart,
headerSlot,
} = props;
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const queued = useQueuedMessage();
const lastRunOutcome = useRef<"succeeded" | "stopped" | "failed" | "readonly" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [searchWindow, setSearchWindow] = useState<readonly [string, string] | null>(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null);
const [caseStage, setCaseStage] = useState<RectificationTimelineStage | null>(() => caseSnapshotState(initialSnapshot)?.stage ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [interviewStopReason, setInterviewStopReason] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null);
const [interviewSessionOutcome, setInterviewSessionOutcome] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null);
const [birthTimeSource, setBirthTimeSource] = useState(() => caseSnapshotState(initialSnapshot)?.birthTimeSource ?? birthTimeSourceFromSnapshot(initialSnapshot));
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const messageActionsRef = useRef<RectificationMessageActions>({
submitChoice() {},
submitStop() {},
copyMessage() {},
regenerateMessage() {},
onFeedback() {},
});
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 runAbort = useRef<AbortController | null>(null);
const snapshotAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const runStartedAtRef = useRef(0);
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
const liveBaseLabelRef = useRef("正在处理…");
const [selectionOfferLock, setSelectionOfferLock] = useState<SelectionCardLock | null>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const composerRemainingId = useId();
// The same anchor-and-follow the consultation surface uses: streamed tokens and
// new cards land the viewport on the bottom only while the reader is there.
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => {
currentQuestionRef.current = currentQuestion;
}, [currentQuestion]);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
const nextCompact = query.matches;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
const stopRun = useCallback(() => {
runAbort.current?.abort();
}, []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
if (value) setQuestionRepairAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
const beginLiveRun = useCallback((baseLabel: string) => {
const now = Date.now();
runStartedAtRef.current = now;
stepStartedAtRef.current = now;
liveToolRef.current = null;
liveBaseLabelRef.current = baseLabel;
}, []);
const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => {
if (liveBaseLabelRef.current !== baseLabel) {
liveBaseLabelRef.current = baseLabel;
stepStartedAtRef.current = Date.now();
}
liveToolRef.current = tool;
return rectificationLiveProgressLabel({
tool,
baseLabel,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
}, []);
useEffect(() => {
if (!busy) return;
const id = window.setInterval(() => {
const label = rectificationLiveProgressLabel({
tool: liveToolRef.current,
baseLabel: liveBaseLabelRef.current,
stepStartedAt: stepStartedAtRef.current,
runStartedAt: runStartedAtRef.current,
});
setMessages((current) => current.map((message) => {
if (message.state !== "thinking" && message.state !== "streaming") return message;
if (!message.activity || message.activity.label === label) return message;
return { ...message, activity: { ...message.activity, label } };
}));
}, 4000);
return () => window.clearInterval(id);
}, [busy]);
useEffect(() => {
if (!candidateResult?.resultId) return;
const canOffer = canShowRectificationSelectionCards(candidateResult);
const adopted = Boolean(candidateResult.selectedTime);
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => applyLiveCandidateOffer(current, {
resultId,
canOffer,
adopted,
}));
});
}, [candidateResult]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: RectificationCaseSnapshotPayload | {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
step_state?: unknown;
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
interview?: { stop_reason?: unknown; session_outcome?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
candidate_range?: unknown;
stage?: unknown;
};
} | null) => {
if (!payload) return;
const snapshot = payload as RectificationCaseSnapshotPayload;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
? payload.case.status
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const nextActionId = typeof payload.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
// Only overwrite when the payload actually carries them: a snapshot without
// a window should leave the timeline showing the last known one rather than
// dropping to the skeleton mid-session.
const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range);
if (nextWindow) setSearchWindow(nextWindow);
const nextStage = caseStageFromSnapshot(payload.case?.stage);
if (nextStage) setCaseStage(nextStage);
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot));
setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot));
setBirthTimeSource(birthTimeSourceFromSnapshot(snapshot));
setCaseSnapshotLoaded(true);
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
setQuestionRepairAttempts(0);
}
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
} else if (acceptedTime) {
setSavedTime(acceptedTime);
setSavedStatus("accepted");
}
}, []);
const loadCaseSnapshot = useCallback(async (
mergeMessages?: (turns: readonly unknown[]) => void,
): Promise<{
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
snapshotAbort.current?.abort();
const controller = new AbortController();
snapshotAbort.current = controller;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store", signal: controller.signal },
);
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) return undefined;
const turns = snapshotTurns(payload);
startTransition(() => {
applyCaseSnapshot(payload);
mergeMessages?.(turns);
});
return {
question: currentQuestionFromSnapshot(payload?.current_question),
turns,
};
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
return undefined;
} finally {
if (snapshotAbort.current === controller) snapshotAbort.current = null;
}
}, [applyCaseSnapshot, caseId, sessionId]);
// Turns that arrive after mount (the parent refreshes the Case after each
// completed turn) only ever fill an empty transcript; a live or finished
// conversation is never overwritten or remounted. Adjusted during render
// from the previous prop, the way React documents it, not in an effect.
const [seededTurns, setSeededTurns] = useState(initialTurns);
if (seededTurns !== initialTurns) {
setSeededTurns(initialTurns);
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
}
// Leaving the surface mid-turn ends the stream and any snapshot read
// instead of letting them write into an unmounted component.
useEffect(() => () => {
runAbort.current?.abort();
snapshotAbort.current?.abort();
}, []);
/**
* Run one agent turn. A `continuation` reuses the live row an earlier step
* (a tapped choice, an adopted candidate) already put at the end of the
* transcript, so the reader sees one uninterrupted working row from the tap
* to the next question instead of a gap and a re-appended row. The caller
* of a continuation already holds `busy`.
*/
const send = useCallback(async (
action: "opening" | "message" | "read_only",
messageText: string,
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || readonly) return;
if (!continuation && busy) return;
setError("");
setPending(true);
const initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
beginLiveRun(initialLabel);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const turnKey = keyCounter.current;
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`;
const pendingFocusId = currentQuestionRef.current?.focus_id;
const liveRow: RenderMessage = {
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() },
};
setMessages((current) => (continuation
? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message))
: [
...(action === "message" && pendingFocusId
? markQuestionAnswered(current, pendingFocusId, "typed")
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
liveRow,
]));
let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
let currentActivity: AgentActivityView | undefined = {
phase: "evidence-validation",
label: initialLabel,
startedAt: Date.now(),
};
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. The loop below never calls
// setMessages for a live turn directly except on `attempt.reset`.
const frames = createStreamFrameBuffer<null>({
initialMeta: null,
flush: (frame) => {
const text = frame.answer;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text,
activityTrace,
completedReceipt,
state: text.trim() ? "streaming" : "thinking",
activity: currentActivity,
}
: message));
},
});
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
caseId,
sessionId,
requestId,
action,
modelId: selectedModelId,
...(action === "message" ? { message: trimmed } : {}),
origin: action === "read_only" ? "choice_click" : "typed",
clientActionId: requestId,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status}`;
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
if (response.status === 402) {
// Keep the conversation on this page; the settings pane covers billing.
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
onOpenBilling?.({ source: "rectification" });
return;
}
if (response.status === 401) setError("请先登录。");
else setError(message);
return;
}
if (!response.body) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError("服务暂时不可用,请稍后再试。");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
let streamFailed = false;
let runFailedMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: {
type?: unknown;
status?: unknown;
text?: unknown;
replace?: unknown;
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
code?: unknown;
activity?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
} catch {
continue;
}
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw = event.replace === true ? event.text : raw + event.text;
activityTrace = freezeLiveThink(activityTrace);
currentActivity = nextActivityView(currentActivity, {
phase: "answer-composition",
label: rememberLiveActivity("正在组织回答…"),
});
frames.setAnswer(raw);
} else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) {
const activity = event.activity;
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: rememberLiveActivity(RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
});
frames.touch();
} else if (event.type === "attempt.reset") {
raw = "";
activityTrace = emptyActivityTrace();
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
currentActivity = nextActivityView(undefined, {
phase: "evidence-validation",
label: rememberLiveActivity("正在处理…", null),
});
frames.reset();
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: "",
thinkingText: undefined,
activityTrace,
state: "thinking",
completedReceipt: undefined,
failed: false,
turnId: undefined,
activity: currentActivity,
}
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
runFailedMessage = typeof event.message === "string" && event.message.trim()
? event.message
: userFacingRunFailure(typeof event.code === "string" ? event.code : null);
} else if (event.type === "error") {
streamFailed = true;
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "run.completed") {
completed = true;
if (typeof event.turnId === "string") completedTurnId = event.turnId;
} else if (event.type === "tool.activity") {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
if (event.status === "started") {
activityTrace = startActivityTraceStep(
activityTrace,
tool,
RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: rememberLiveActivity(RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityTrace = completeActivityTraceStep(
activityTrace,
tool,
RECTIFICATION_TOOL_DONE_LABELS[tool],
);
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
status: event.status,
methods: event.status === "completed" && Array.isArray(event.methods)
? event.methods.filter(isPublicRectificationMethod)
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
}
}
}
frames.settle();
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (succeeded) {
return [{
...message,
text: parsed.text,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: false,
turnId: completedTurnId,
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) {
return [{
...message,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: true,
activity: undefined,
}];
}
return [];
}));
if (!succeeded && raw.trim()) {
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
} else if (!succeeded && runFailedMessage) {
setError((current) => current || runFailedMessage);
} else if (!succeeded && completedReceipt.failedTool) {
setError((current) => current || userFacingRunFailure("run_failed"));
}
if (succeeded) {
runOutcome = "succeeded";
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
}
});
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
} else {
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
}
});
}
} catch (caught) {
frames.settle();
const aborted = isAbortError(caught);
if (aborted) {
runOutcome = "stopped";
setMessages((current) => current.map((message) => {
if (message.renderKey !== assistantRenderKey) return message;
return {
...message,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled" as const,
completedReceipt,
failed: false,
stopped: true,
turnId: completedTurnId,
activity: undefined,
};
}));
return;
}
setError("生时校正暂时不可用,请稍后再试。");
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
return hasActivityReceipt(completedReceipt)
? [{
...message,
text: "",
state: "settled",
completedReceipt,
failed: true,
}]
: [];
}));
} finally {
frames.dispose();
if (runAbort.current === abortController) runAbort.current = null;
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
setPending(false);
}
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;
const outcome = lastRunOutcome.current;
lastRunOutcome.current = null;
if (!outcome || !queued.text) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(outcome) === "send" && !readonly) {
void send("message", next);
return;
}
setDraft((current) => appendQueuedText(current, next));
}, [busy, readonly, send]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId);
const existing = choiceActionIds.current.get(key);
if (existing) return existing;
const next = globalThis.crypto.randomUUID();
choiceActionIds.current.set(key, next);
return next;
}, []);
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: Readonly<{
focusId?: string;
questionId?: string | null;
probeId?: string | null;
caseRevision?: number | null;
}>,
) => {
const focusId = override?.focusId ?? choiceCard?.focus_id ?? (
action === STOP_ACTION && currentQuestion?.kind === "collect_spoken"
? currentQuestion.focus_id
: null
);
if (busy || readonly) return;
if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return;
if (focusId && !isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null;
const probeId = override?.probeId ?? choiceCard?.probe_id;
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
const actionId = actionIdForChoice(focusId ?? caseId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
beginLiveRun(recordingLabel);
setMessages((current) => [
...markQuestionAnswered(current, focusId ?? "", optionId),
{
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
label: recordingLabel,
startedAt: Date.now(),
},
},
]);
setPending(true);
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
caseId,
sessionId,
requestId: actionId,
action,
actionId,
...(focusId ? { focusId } : {}),
...(questionId ? { questionId } : {}),
probeId: probeId ?? null,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision,
origin: "choice_click",
clientActionId: actionId,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
setError(payload?.message || payload?.error || `请求失败(${response.status}`);
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
await loadCaseSnapshot((turns) => {
if (willContinue) {
setMessages((current) => mergeTurnQuestions(current, turns));
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
setMessages((current) => {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
mergeTurnQuestions(withoutPlaceholder, turns),
messagesFromTurns(turns as readonly PersistedTurn[]),
);
if (withHistory.length > withoutPlaceholder.length) return withHistory;
return [
...withHistory,
{
role: "assistant" as const,
text: narration,
renderKey: assistantRenderKey,
state: "settled" as const,
activity: undefined,
},
];
});
});
if (willContinue) {
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
}
} catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。");
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setPending(false);
}
}, [
beginLiveRun,
caseId,
choiceCard,
currentQuestion,
loadCaseSnapshot,
onCompleted,
onMessagesChange,
onProfileIncomplete,
readonly,
send,
sessionId,
setPending,
]);
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
return;
}
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
onOpeningConsumed?.();
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
setError("");
setAcceptingCandidateId(candidateId);
setPending(true);
// Adoption is a server round trip followed by an agent turn; the reader sees
// one live row for the whole of it instead of a greyed button.
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
keyCounter.current += 1;
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
beginLiveRun(adoptingLabel);
setMessages((current) => [
...current,
{
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
},
]);
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
{
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
sessionId,
resultId: candidateResult.resultId,
candidateId,
requestId: globalThis.crypto.randomUUID(),
mode: "range",
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") {
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
}
setCandidateResult((current) => {
if (!current) return current;
const selectedTime = typeof payload.saved_time === "string" ? payload.saved_time.slice(0, 5) : current.selectedTime;
const next = {
...current,
selectedTime,
selectionKind: "user_accepted" as const,
};
const houseTable = workingRectificationHouseTable(next);
const recastMeaning = natalRecastMeaning(houseTable);
return {
...next,
houseTable: houseTable ?? current.houseTable,
natalRecast: houseTable && recastMeaning
? {
time: houseTable.time,
lagna: houseTable.lagna,
user_meaning: recastMeaning,
unique_minute_claim: false as const,
confirmation_allowed: false as const,
}
: current.natalRecast,
};
});
setSavedTime(payload.saved_time);
setSavedStatus("accepted");
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
await loadCaseSnapshot();
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setAcceptingCandidateId(null);
setPending(false);
}
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question));
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
async function regenerateMessage(message: RenderMessage) {
if (!message.turnId || regeneratingMessageKey || busy || readonly) return;
setError("");
setRegeneratingMessageKey(message.renderKey);
const previousText = message.text;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/turns/${encodeURIComponent(message.turnId)}/regenerate`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") {
throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答");
}
const nextText = payload.assistantMessage;
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: nextText, state: "settled" }
: item));
} catch (caught) {
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: previousText, state: "settled" }
: item));
setError(caught instanceof Error ? caught.message : "暂时无法重新生成回答");
} finally {
setRegeneratingMessageKey((current) => current === message.renderKey ? null : current);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
if (readonly) return;
if (busy || regeneratingMessageKey) {
if (queued.enqueue(draft)) setDraft("");
return;
}
const text = draft;
setDraft("");
await send("message", text);
}
const latestRegeneratableKey = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.turnId)
&& Boolean(message.text)
))?.renderKey;
const latestSettledAssistant = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
const latestLiveQuestion = [...messages].reverse().find((message) => (
message.role === "assistant"
&& message.question
&& !questionIsAnswered(message.question)
))?.question ?? null;
const showLiveChoiceCard = Boolean(
latestLiveQuestion
&& latestLiveQuestion.options?.length === 4
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
&& choiceCard
&& choiceCard.focus_id === latestLiveQuestion.focus_id
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
// Cheap arithmetic over at most a dozen candidate minutes; recomputed with
// the render rather than memoised, matching the rest of this component.
const timelineView = buildRectificationTimeline({
searchWindow,
credibleRange: candidateResult?.credibleRange ?? null,
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const liveSelectionCardKey = persistedOfferKey
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
// Same render-time store update as seededTurns: the lock must be current
// before this paint (BUG-620). A ref write here trips react-hooks/refs.
const nextOfferLock = nextSelectionCardLock(selectionOfferLock, {
resultId: candidateResult?.resultId,
key: liveSelectionCardKey,
});
if (nextOfferLock !== selectionOfferLock) {
setSelectionOfferLock(nextOfferLock);
}
const selectionCardMessageKey = resolveSelectionCardMessageKey({
persistedOfferKey,
fallbackKey: latestSettledAssistant?.renderKey,
locked: nextOfferLock,
resultId: candidateResult?.resultId,
selectedTime: candidateResult?.selectedTime,
canOffer: canOfferCards,
});
// Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card
// for `busy` is what made the three columns vanish on 「更像这个」.
const keepSelectionCardsWhileBusy = Boolean(
acceptingCandidateId || candidateResult?.selectedTime,
);
const liveInterviewBlockingAdopt = Boolean(
candidateResult
&& !candidateResult.selectedTime
&& messages.some((message) => (
interviewQuestionBlocksAdoptOffer(message.question, false)
)),
);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& (!busy || keepSelectionCardsWhileBusy)
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
);
const showReadonlyRange = Boolean(
canShowRectificationReadonlyRange(candidateResult)
&& latestSettledAssistant
&& !showSelectionCards,
);
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const deadChoice = interviewChoiceCardUnavailable({
questionKind: currentQuestion?.kind,
hasChoiceCard: Boolean(choiceCard),
questionId: currentQuestion?.question_id,
});
const liveQuestionOnMessages = Boolean(
latestSettledAssistant
&& latestSettledAssistant.question
&& currentQuestion
&& latestSettledAssistant.question.focus_id === currentQuestion.focus_id
&& !questionIsAnswered(latestSettledAssistant.question)
&& !deadChoice,
);
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server. Collect-waiting is the training-gate gap:
// no next card, keep the case open, do not show 「没有拿到下一个问题」.
const collectWaiting = interviewCollectWaiting({
stopReason: interviewStopReason,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
questionMissing: currentQuestion === null,
offeringCards: showSelectionCards,
});
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null,
nextUserActionId,
collectWaiting: collectWaiting && !deadChoice,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
stopReason: interviewStopReason,
deadChoice,
questionId: currentQuestion?.question_id ?? null,
hasChoiceCard: Boolean(choiceCard),
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
snapshotLoaded: caseSnapshotLoaded,
resumableCase,
retryAttempts: questionRetryAttempts,
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
});
const verifiedIdleCopy = questionGap === "verified_idle"
? postAdoptVerifyDoneCopy(
savedTime,
messages.some((message) => message.question?.kind === "reverse_verify"),
)
: null;
const conversationState = rectificationConversationState({
messageCount: messages.length,
busy,
readonly,
shouldStartOpening,
openingStarted: openingRequested,
});
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a repair button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
refreshOnVisible: false,
onPoll: () => {
setQuestionRetryAttempts((current) => current + 1);
void refetchQuestion();
},
});
async function refetchQuestion() {
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
}
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
setQuestionRepairing(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`,
{
method: "POST",
headers: { "content-type": "application/json" },
cache: "no-store",
body: JSON.stringify({ sessionId }),
},
);
const payload = await response.json().catch(() => null);
if (response.ok && payload) {
startTransition(() => {
applyCaseSnapshot(payload);
});
const recovered = Boolean(
currentQuestionFromSnapshot(payload.current_question)
|| parseRectificationChoiceCard(payload.choice_card)
|| payload.case?.accepted_time
|| payload.case?.confirmed_time
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
setQuestionRepairAttempts((current) => current + 1);
} finally {
setQuestionRepairing(false);
}
}
// A Case with no turns and no automatic first turn (the server never starts
// an opening for a resumed Case) needs a way to begin; the server suppresses
// a duplicate opening, so this is safe to press twice.
function startOpeningManually() {
if (readonly || busy || openingStarted.current) return;
openingStarted.current = true;
setOpeningRequested(true);
void send("opening", "");
}
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void submitStructuredChoice(CHOICE_ACTION, key);
}
function submitStop() {
if (choiceCard) {
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
return;
}
if (currentQuestion?.kind === "collect_spoken" && currentQuestion.focus_id) {
void submitStructuredChoice(STOP_ACTION, "stop", {
focusId: currentQuestion.focus_id,
questionId: currentQuestion.question_id,
});
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
}
useEffect(() => {
messageActionsRef.current = {
submitChoice,
submitStop,
copyMessage,
regenerateMessage,
onFeedback(renderKey, requested) {
setFeedback((current) => ({
...current,
[renderKey]: toggleRectificationFeedback(current[renderKey], requested),
}));
},
};
});
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
birthTimeSource={birthTimeSource}
expanded={boardOpen}
boardId={boardId}
onOpen={toggleBoard}
/>
) : null;
return (
<div
ref={workspace}
className={`rectification-workspace${candidateResult ? "" : " is-board-empty"}${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
{/* Its own grid row, above and outside the scroll container, so
scrollHeight is untouched and the scroll anchor needs no changes. */}
<RectificationTimeline view={timelineView} />
<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()}
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
</div>
)}
{messages.map((message) => {
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
return (
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
>
<RectificationMessageEntry
message={message}
busy={busy}
readonly={readonly}
regenerating={regenerating}
canRegenerate={canRegenerate}
currentQuestionFocusId={currentQuestion?.focus_id ?? null}
interactive={!busy && !readonly && regeneratingMessageKey === null}
liveChoiceCard={choiceCard}
choiceNonce={choiceNonce}
savedTime={savedTime}
copied={copiedMessageKey === message.renderKey}
feedback={feedback[message.renderKey]}
actionsRef={messageActionsRef}
/>
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<>
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
adoptLocked={liveInterviewBlockingAdopt}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
{verifiedIdleCopy}
</p>
)}
</>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
sessionOutcome={candidateResult.sessionOutcome}
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
/>
)}
</div>
);
})}
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
{persistedQuestionSurface({
questionKind: currentQuestion.kind,
hasChoiceCard: Boolean(choiceCard),
questionPersisted: true,
}) === "choice_card" && choiceCard ? (
<>
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}:persisted`}
variant="embedded"
card={choiceCard}
pending={busy}
disabled={busy || readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
</>
) : (
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
)}
</div>
</div>
)}
{questionGap === "preparing" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap">
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
</div>
)}
{questionGap === "delivered" && !showSelectionCards && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_DELIVERED_COPY}
</p>
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
) : (
<>
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</>
)}
</div>
)}
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
<p className="rectification-pending-note" role="status">
{verifiedIdleCopy}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
已确认校正时间:{savedTime}
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
<div className="rectification-terminal-actions">
<p className="rectification-terminal-note">该校正已结束,只能查看历史。</p>
<Button type="button" onClick={onRestart}>再次校正</Button>
</div>
)}
</div>
</section>
<div className="composer-wrap">
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<ChatComposer
inputRef={composer}
value={draft}
remainingId={composerRemainingId}
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
: questionGap === "collect_waiting"
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={readonly}
submitLabel="发送"
submitBlocked={readonly || Boolean(queued.text)}
stopVisible={busy}
stopLabel="停止回答"
stopTitle="停止当前推理,已生成内容会保留"
queued={queued.text ? { text: queued.text, onRecall: () => {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(recalled, draft));
composer.current?.focus();
} } : undefined}
onSubmit={submit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
toolbar={(
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
)}
onStop={stopRun}
/>
</div>
</div>
<RectificationBoard
result={candidateResult}
declaredTime={declaredTime}
birthTimeSource={birthTimeSource}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}