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
1864 lines
74 KiB
TypeScript
1864 lines
74 KiB
TypeScript
"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>
|
||
);
|
||
}
|