3c33e59814
The choice card now marks the tapped option with a check and 已选择 and, while the tap is being recorded, carries the same live row shape as a timeline step instead of only a wait cursor. Before any candidate exists the board's clock shows the declared birth minute, its body says what fills it later, and the column narrows to minmax(16rem, 18rem); the compact peek reads "填报 HH:MM". No house table is invented for the declared time — the Case API does not provide one. BUG-508, BUG-509 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
1750 lines
69 KiB
TypeScript
1750 lines
69 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { parseAgentReply } from "@/lib/agent-reply";
|
||
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } 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_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,
|
||
isRecommendedRectificationCandidate,
|
||
natalRecastMeaning,
|
||
parseRectificationCandidateResult,
|
||
workingRectificationHouseTable,
|
||
type RectificationCandidateResult,
|
||
} from "@/lib/rectification-candidate-result";
|
||
import {
|
||
diffRectificationBoard,
|
||
RECTIFICATION_BOARD_SPLIT_MIN_PX,
|
||
} from "@/lib/rectification-board-model";
|
||
import { membershipHref } from "@/lib/membership";
|
||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||
import {
|
||
rectificationAdoptingLabel,
|
||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||
RECTIFICATION_EMPTY_COPY,
|
||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
|
||
RECTIFICATION_QUESTION_PREPARING_LABEL,
|
||
RECTIFICATION_QUESTION_RELOAD_LABEL,
|
||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||
RECTIFICATION_STOPPED_NOTICE,
|
||
rectificationConversationState,
|
||
rectificationInitialLiveLabel,
|
||
rectificationQuestionGapState,
|
||
type RectificationCaseSnapshotPayload,
|
||
} from "@/lib/rectification-surface-state";
|
||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||
import {
|
||
isPublicRectificationActivity,
|
||
isPublicRectificationMethod,
|
||
isPublicRectificationTool,
|
||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
|
||
import {
|
||
CHOICE_ACTION,
|
||
STOP_ACTION,
|
||
isStructuredChoiceUserText,
|
||
shouldContinueAfterStructuredChoice,
|
||
stableChoiceActionKey,
|
||
type ChoiceOptionId,
|
||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
|
||
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, 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 { CharacterRemaining } from "./character-remaining";
|
||
import { ChatComposer } from "./chat-composer";
|
||
import { ChatMessageRow } from "./chat-message-row";
|
||
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
|
||
import { JumpToLatestButton } from "./jump-to-latest-button";
|
||
import {
|
||
ChatMessageActions,
|
||
toggleChatMessageFeedback,
|
||
} from "./chat-message-actions";
|
||
import { ModelSelector } from "./model-selector";
|
||
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
|
||
import { RectificationChoiceCard } from "./rectification-choice-card";
|
||
import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question";
|
||
import { Button } from "@/components/ui/button";
|
||
|
||
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,
|
||
}: Readonly<{ range: readonly [string, string] }>) {
|
||
return (
|
||
<p className="rectification-readonly-range" role="status">
|
||
目前范围 {range[0]}–{range[1]},还在收窄
|
||
</p>
|
||
);
|
||
}
|
||
|
||
function RectificationCandidateCards({
|
||
result,
|
||
acceptingCandidateId,
|
||
readonly,
|
||
onAccept,
|
||
}: Readonly<{
|
||
result: RectificationCandidateResult;
|
||
acceptingCandidateId: string | null;
|
||
readonly: boolean;
|
||
onAccept: (candidateId: string) => void;
|
||
}>) {
|
||
return (
|
||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||
<div className="rectification-candidates-heading">
|
||
<strong>当前可能的出生时间</strong>
|
||
<span>相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。</span>
|
||
</div>
|
||
<div className="rectification-candidate-list">
|
||
{result.candidates.map((candidate) => {
|
||
const selected = result.selectedTime === candidate.time;
|
||
const recommended = isRecommendedRectificationCandidate(result, candidate);
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`rectification-candidate${selected ? " is-selected" : ""}`}
|
||
key={candidate.candidateId}
|
||
disabled={selected || Boolean(acceptingCandidateId) || readonly}
|
||
onClick={() => onAccept(candidate.candidateId)}
|
||
>
|
||
<span className="rectification-candidate-time">
|
||
<strong>{candidate.time}</strong>
|
||
{selected && <span className="rectification-candidate-badge">已采用</span>}
|
||
{recommended && <span className="rectification-candidate-badge">当前推荐</span>}
|
||
</span>
|
||
<span className="rectification-candidate-support">相对支持度 {candidate.relativeSupport}</span>
|
||
<span className="rectification-candidate-action">
|
||
{selected ? "已采用" : acceptingCandidateId === candidate.candidateId ? "正在采用…" : result.selectedTime ? "改选为此时间" : "采用此时间"}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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;
|
||
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
|
||
onOpeningConsumed?: () => void;
|
||
pendingConsultationQuestion?: string | null;
|
||
onRestart?: () => void;
|
||
headerSlot: HTMLElement | null;
|
||
}>;
|
||
|
||
type RenderMessage = ChatMessageView & {
|
||
renderKey: string;
|
||
completedReceipt?: CompletedActivityReceiptView;
|
||
failed?: boolean;
|
||
turnId?: string;
|
||
activityTrace?: readonly AgentActivityTraceItem[];
|
||
question?: TurnQuestion;
|
||
candidateOffer?: Readonly<{ resultId: string }>;
|
||
};
|
||
|
||
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
|
||
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
|
||
for (const item of turns) {
|
||
if (!item || typeof item !== "object") continue;
|
||
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
|
||
if (typeof turn.id !== "string") continue;
|
||
byId.set(turn.id, {
|
||
question: parseTurnQuestion(turn.question),
|
||
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
|
||
});
|
||
}
|
||
return current.map((message) => {
|
||
if (!message.turnId || !byId.has(message.turnId)) return message;
|
||
const next = byId.get(message.turnId);
|
||
const question = next?.question ?? undefined;
|
||
return {
|
||
...message,
|
||
question: question ?? undefined,
|
||
candidateOffer: next?.offerResultId
|
||
? { resultId: next.offerResultId }
|
||
: message.candidateOffer,
|
||
};
|
||
});
|
||
}
|
||
|
||
function markQuestionAnswered(
|
||
current: RenderMessage[],
|
||
focusId: string,
|
||
selected: ChoiceKey | "stop" | "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 appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
|
||
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
||
const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => (
|
||
message.role === "assistant"
|
||
&& message.turnId
|
||
&& !known.has(message.turnId)
|
||
));
|
||
return extras.length ? [...current, ...extras] : current;
|
||
}
|
||
|
||
function choiceCardFromQuestion(
|
||
question: TurnQuestion,
|
||
live: ChoiceCardModel | null,
|
||
): ChoiceCardModel | null {
|
||
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
|
||
return { ...live, prompt: question.prompt };
|
||
}
|
||
if (!question.options || question.options.length !== 4) return null;
|
||
return {
|
||
question_id: question.question_id,
|
||
method_id: "",
|
||
prompt: question.prompt,
|
||
why: "",
|
||
varga: null,
|
||
choice_mode: CHOICE_MODE,
|
||
options: question.options.map((option) => ({
|
||
key: option.key,
|
||
label: option.label,
|
||
answer_class: "unsure",
|
||
role: "primary",
|
||
})),
|
||
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
|
||
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
|
||
scoring: question.kind !== "reverse_verify",
|
||
probe_id: question.probe_id,
|
||
case_revision: null,
|
||
focus_id: question.focus_id,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}>;
|
||
|
||
/** 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,
|
||
};
|
||
}
|
||
|
||
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||
const {
|
||
caseId,
|
||
sessionId,
|
||
readonly,
|
||
shouldStartOpening,
|
||
initialTurns,
|
||
initialSnapshot,
|
||
declaredTime,
|
||
models,
|
||
selectedModelId,
|
||
onSelectModel,
|
||
onMessagesChange,
|
||
onCompleted,
|
||
onPendingChange,
|
||
onProfileIncomplete,
|
||
onSaved,
|
||
onOpeningConsumed,
|
||
pendingConsultationQuestion,
|
||
onRestart,
|
||
headerSlot,
|
||
} = props;
|
||
|
||
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||
const [draft, setDraft] = useState("");
|
||
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 [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||
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 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 offerSectionRef = useRef<HTMLDivElement | null>(null);
|
||
const offerFlashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const [offerFlash, setOfferFlash] = useState(false);
|
||
const runStartedAtRef = useRef(0);
|
||
const stepStartedAtRef = useRef(0);
|
||
const liveToolRef = useRef<string | null>(null);
|
||
const liveBaseLabelRef = useRef("正在处理…");
|
||
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]);
|
||
|
||
useEffect(() => () => {
|
||
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
|
||
}, []);
|
||
|
||
const scrollToAdoptOffer = useCallback(() => {
|
||
offerSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
setOfferFlash(true);
|
||
if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current);
|
||
offerFlashTimer.current = setTimeout(() => setOfferFlash(false), 600);
|
||
}, []);
|
||
|
||
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);
|
||
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) => {
|
||
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
|
||
if (adopted && current.some((message) => message.candidateOffer)) {
|
||
return current.map((message) => (
|
||
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
|
||
));
|
||
}
|
||
if (!canOffer || adopted) return current;
|
||
const owner = [...current].reverse().find((message) => (
|
||
message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& Boolean(message.text)
|
||
&& !message.failed
|
||
));
|
||
if (!owner) return current;
|
||
return current.map((message) => (
|
||
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
|
||
));
|
||
});
|
||
});
|
||
}, [candidateResult]);
|
||
|
||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||
// from parsing agent text or hidden sentinels.
|
||
const applyCaseSnapshot = useCallback((payload: {
|
||
latest_result?: unknown;
|
||
current_question?: unknown;
|
||
choice_card?: unknown;
|
||
turns?: unknown;
|
||
question_source?: unknown;
|
||
case?: {
|
||
status?: unknown;
|
||
accepted_time?: unknown;
|
||
confirmed_time?: unknown;
|
||
};
|
||
} | null) => {
|
||
if (!payload) return;
|
||
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;
|
||
setCandidateResult(nextCandidate);
|
||
setCurrentQuestion(nextQuestion);
|
||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||
setChoiceCard(nextChoice);
|
||
setCaseStatus(nextCaseStatus);
|
||
setCaseSnapshotLoaded(true);
|
||
if (confirmedTime) {
|
||
setSavedTime(confirmedTime);
|
||
setSavedStatus("confirmed");
|
||
} else if (acceptedTime) {
|
||
setSavedTime(acceptedTime);
|
||
setSavedStatus("accepted");
|
||
}
|
||
}, []);
|
||
|
||
const loadCaseSnapshot = useCallback(async (): 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;
|
||
applyCaseSnapshot(payload);
|
||
return {
|
||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||
turns: snapshotTurns(payload),
|
||
};
|
||
} 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,
|
||
]));
|
||
setDraft("");
|
||
|
||
let raw = "";
|
||
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) {
|
||
// Say why before the page leaves, instead of vanishing mid-chat.
|
||
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
|
||
window.setTimeout(() => {
|
||
window.location.assign(membershipHref("rectification"));
|
||
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
|
||
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: rectificationToolActivityPhase(tool),
|
||
label: rememberLiveActivity(RECTIFICATION_TOOL_DONE_LABELS[tool], tool),
|
||
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) {
|
||
const snapshot = await loadCaseSnapshot();
|
||
if (snapshot?.turns.length) {
|
||
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
|
||
}
|
||
onMessagesChange?.([
|
||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||
{ role: "assistant", text: parsed.text },
|
||
]);
|
||
onCompleted?.();
|
||
}
|
||
} catch (caught) {
|
||
frames.settle();
|
||
const aborted = caught instanceof DOMException
|
||
? caught.name === "AbortError"
|
||
: caught instanceof Error && caught.name === "AbortError";
|
||
if (aborted) {
|
||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||
if (message.renderKey !== assistantRenderKey) return [message];
|
||
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
|
||
return [{
|
||
...message,
|
||
text: raw,
|
||
activityTrace: completeActivityTrace(activityTrace),
|
||
state: "settled",
|
||
completedReceipt,
|
||
failed: true,
|
||
turnId: completedTurnId,
|
||
}];
|
||
}
|
||
return [];
|
||
}));
|
||
// The reader pressed stop: what streamed stays, and the notice says so
|
||
// rather than blaming the service.
|
||
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
|
||
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;
|
||
setPending(false);
|
||
}
|
||
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
|
||
|
||
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,
|
||
optionId: ChoiceKey | "stop",
|
||
override?: Readonly<{
|
||
focusId: string;
|
||
questionId: string | null;
|
||
probeId?: string | null;
|
||
caseRevision?: number | null;
|
||
}>,
|
||
) => {
|
||
const focusId = override?.focusId ?? choiceCard?.focus_id;
|
||
if (!focusId || busy || readonly) return;
|
||
if (!override && !choiceCard) return;
|
||
if (!isPersistedFocusId(focusId)) {
|
||
setError("当前选择题已失效,请等待下一问。");
|
||
return;
|
||
}
|
||
const questionId = override?.questionId ?? choiceCard?.question_id;
|
||
const probeId = override?.probeId ?? choiceCard?.probe_id;
|
||
const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0;
|
||
const actionId = actionIdForChoice(focusId, 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);
|
||
try {
|
||
const response = await fetch("/api/rectification/agent", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
caseId,
|
||
sessionId,
|
||
requestId: actionId,
|
||
action,
|
||
actionId,
|
||
focusId,
|
||
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);
|
||
onCompleted?.();
|
||
const snapshot = await loadCaseSnapshot();
|
||
const turns = snapshot?.turns ?? [];
|
||
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).
|
||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||
} else {
|
||
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),
|
||
turns,
|
||
);
|
||
if (withHistory.length > withoutPlaceholder.length) return withHistory;
|
||
return [
|
||
...withHistory,
|
||
{
|
||
role: "assistant" as const,
|
||
text: narration,
|
||
renderKey: assistantRenderKey,
|
||
state: "settled" as const,
|
||
activity: undefined,
|
||
},
|
||
];
|
||
});
|
||
onMessagesChange?.([
|
||
{ role: "assistant", text: narration },
|
||
]);
|
||
}
|
||
} catch {
|
||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||
setChoiceNonce((current) => current + 1);
|
||
setError("选择题处理失败,请稍后重试。");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}, [
|
||
beginLiveRun,
|
||
caseId,
|
||
choiceCard,
|
||
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() },
|
||
},
|
||
]);
|
||
try {
|
||
const response = await fetch(
|
||
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
|
||
{
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
sessionId,
|
||
resultId: candidateResult.resultId,
|
||
candidateId,
|
||
requestId: globalThis.crypto.randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
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) {
|
||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
||
} finally {
|
||
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();
|
||
await send("message", draft);
|
||
}
|
||
|
||
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
|
||
&& (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify")
|
||
&& !busy
|
||
&& !readonly
|
||
&& regeneratingMessageKey === null,
|
||
);
|
||
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
|
||
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
|
||
const selectionCardMessageKey = persistedOfferKey
|
||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||
const showSelectionCards = Boolean(
|
||
candidateResult
|
||
&& selectionCardMessageKey
|
||
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
|
||
);
|
||
const showReadonlyRange = Boolean(
|
||
canShowRectificationReadonlyRange(candidateResult)
|
||
&& latestSettledAssistant
|
||
&& !showSelectionCards,
|
||
);
|
||
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
|
||
? currentQuestion.prompt
|
||
: null;
|
||
const showCollectStop = Boolean(
|
||
latestLiveQuestion
|
||
&& latestLiveQuestion.kind === "collect_spoken"
|
||
&& currentQuestion?.focus_id === latestLiveQuestion.focus_id
|
||
&& !questionIsAnswered(latestLiveQuestion)
|
||
&& !busy
|
||
&& !readonly
|
||
&& regeneratingMessageKey === null,
|
||
);
|
||
const adoptedRangeLabel = candidateResult?.credibleRange
|
||
? `${candidateResult.credibleRange[0]}–${candidateResult.credibleRange[1]}`
|
||
: null;
|
||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||
const liveQuestionOnMessages = messages.some((message) => (
|
||
message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& message.question
|
||
&& currentQuestion
|
||
&& message.question.focus_id === currentQuestion.focus_id
|
||
&& !questionIsAnswered(message.question)
|
||
));
|
||
// 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 reload button) — never bare copy telling the
|
||
// reader to wait for the server.
|
||
const questionGap = rectificationQuestionGapState({
|
||
liveQuestionVisible: liveQuestionOnMessages,
|
||
questionMissing: currentQuestion === null,
|
||
questionLoadFailed: questionSource === "unavailable",
|
||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
|
||
busy,
|
||
readonly,
|
||
regenerating: regeneratingMessageKey !== null,
|
||
snapshotLoaded: caseSnapshotLoaded,
|
||
resumableCase,
|
||
retryAttempts: questionRetryAttempts,
|
||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
});
|
||
const conversationState = rectificationConversationState({
|
||
messageCount: messages.length,
|
||
busy,
|
||
readonly,
|
||
shouldStartOpening,
|
||
openingStarted: openingRequested,
|
||
});
|
||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||
|
||
// 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 reload 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();
|
||
}
|
||
function reloadQuestion() {
|
||
setQuestionRetryAttempts(0);
|
||
void refetchQuestion();
|
||
}
|
||
|
||
// 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) return;
|
||
void submitStructuredChoice(STOP_ACTION, "stop");
|
||
}
|
||
|
||
function submitCollectStop() {
|
||
if (!latestLiveQuestion || latestLiveQuestion.kind !== "collect_spoken") return;
|
||
void submitStructuredChoice(STOP_ACTION, "stop", {
|
||
focusId: latestLiveQuestion.focus_id,
|
||
questionId: latestLiveQuestion.question_id,
|
||
probeId: latestLiveQuestion.probe_id,
|
||
caseRevision: choiceCard?.case_revision ?? 0,
|
||
});
|
||
}
|
||
|
||
const boardPeek = compactBoard && !boardOpen ? (
|
||
<RectificationBoardPeek
|
||
result={candidateResult}
|
||
declaredTime={declaredTime}
|
||
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}>
|
||
<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 showActions = message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& !message.failed
|
||
&& Boolean(message.text);
|
||
const regenerating = regeneratingMessageKey === message.renderKey;
|
||
const canRegenerate = message.renderKey === latestRegeneratableKey
|
||
&& !busy
|
||
&& !readonly
|
||
&& regeneratingMessageKey === null;
|
||
// Both surfaces render the same step timeline: the tool trace and receipt
|
||
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
|
||
// the queued row until real events fill it in, never a staged label.
|
||
const displayedMessage: RenderMessage = regenerating
|
||
? {
|
||
...message,
|
||
text: "",
|
||
thinkingText: undefined,
|
||
state: "thinking" as const,
|
||
activity: undefined,
|
||
activityTrace: emptyActivityTrace(),
|
||
completedReceipt: undefined,
|
||
timeline: [],
|
||
}
|
||
: {
|
||
...message,
|
||
timeline: rectificationTimelineRows({
|
||
trace: message.activityTrace,
|
||
receipt: message.completedReceipt,
|
||
activity: message.activity,
|
||
settled: message.state === "settled",
|
||
}),
|
||
};
|
||
const vargaSentence = !message.failed
|
||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||
: null;
|
||
const question = regenerating ? undefined : message.question;
|
||
const liveQuestion = Boolean(
|
||
question
|
||
&& currentQuestion?.focus_id === question.focus_id
|
||
&& !questionIsAnswered(question)
|
||
&& !busy
|
||
&& !readonly
|
||
&& regeneratingMessageKey === null
|
||
&& (
|
||
question.kind === "collect_spoken"
|
||
|| (
|
||
choiceCard
|
||
&& choiceCard.focus_id === question.focus_id
|
||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||
)
|
||
),
|
||
);
|
||
const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null;
|
||
const afterAnswer = question && displayedMessage.state === "settled"
|
||
? (
|
||
<div className="rectification-message-question">
|
||
<p className="rectification-message-question__prompt">{question.prompt}</p>
|
||
{embeddedCard && (
|
||
<RectificationChoiceCard
|
||
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
|
||
variant="embedded"
|
||
card={embeddedCard}
|
||
pending={busy && liveQuestion}
|
||
disabled={!liveQuestion}
|
||
selectedKey={question.answer_option ?? ""}
|
||
onSelect={submitChoice}
|
||
onStop={submitStop}
|
||
/>
|
||
)}
|
||
{question.status === "skipped" && (
|
||
<p className="rectification-question-skipped" role="status">
|
||
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
: undefined;
|
||
return (
|
||
<div
|
||
key={message.renderKey}
|
||
className={[
|
||
"rectification-message-wrap rectification-message-entry",
|
||
offerFlash && message.renderKey === selectionCardMessageKey
|
||
? "rectification-message-flash"
|
||
: "",
|
||
].filter(Boolean).join(" ")}
|
||
ref={showSelectionCards && message.renderKey === selectionCardMessageKey ? offerSectionRef : undefined}
|
||
>
|
||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||
<ChatMessageRow
|
||
message={displayedMessage}
|
||
showActivity={displayedMessage.state !== "settled"}
|
||
vargaSentence={vargaSentence}
|
||
afterAnswer={afterAnswer}
|
||
/>
|
||
)}
|
||
{showActions && !regenerating && (
|
||
<ChatMessageActions
|
||
feedback={feedback[message.renderKey]}
|
||
copied={copiedMessageKey === message.renderKey}
|
||
canRegenerate={canRegenerate}
|
||
onFeedback={(requested) => setFeedback((current) => ({
|
||
...current,
|
||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
|
||
}))}
|
||
onCopy={() => void copyMessage(message)}
|
||
onRegenerate={() => void regenerateMessage(message)}
|
||
/>
|
||
)}
|
||
{showSelectionCards && message.renderKey === selectionCardMessageKey && candidateResult && (
|
||
<RectificationCandidateCards
|
||
result={candidateResult}
|
||
acceptingCandidateId={acceptingCandidateId}
|
||
readonly={readonly || busy || regeneratingMessageKey !== null}
|
||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||
/>
|
||
)}
|
||
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
|
||
<RectificationReadonlyRange range={candidateResult.credibleRange} />
|
||
)}
|
||
</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 === "unavailable" && (
|
||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||
<Button type="button" variant="outline" onClick={reloadQuestion}>
|
||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{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} />
|
||
)}
|
||
{savedTime && savedStatus === "accepted" && (
|
||
<div className="rectification-adopt-status" role="status">
|
||
<span>
|
||
已采用 {savedTime}
|
||
{adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""}
|
||
</span>
|
||
{showSelectionCards && (
|
||
<button
|
||
type="button"
|
||
className="rectification-adopt-status__link"
|
||
onClick={scrollToAdoptOffer}
|
||
>
|
||
改选
|
||
</button>
|
||
)}
|
||
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
|
||
</div>
|
||
)}
|
||
<ChatComposer
|
||
inputRef={composer}
|
||
value={draft}
|
||
remainingId={composerRemainingId}
|
||
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||
placeholder={readonly
|
||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||
: showLiveChoiceCard
|
||
? "点上面的选项即可;想补一句细节再写"
|
||
: collectSpokenPrompt
|
||
? "请回答上面的问题…"
|
||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
|
||
inputDisabled={!canSend}
|
||
submitLabel="发送"
|
||
submitBlocked={!canSend}
|
||
stopVisible={busy}
|
||
stopLabel="停止回答"
|
||
stopTitle="停止当前推理,已生成内容会保留"
|
||
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();
|
||
}
|
||
}}
|
||
onStop={stopRun}
|
||
/>
|
||
{showCollectStop && (
|
||
<button
|
||
type="button"
|
||
className="rectification-collect-stop"
|
||
disabled={!canSend}
|
||
onClick={submitCollectStop}
|
||
>
|
||
{CHOICE_STOP_LABEL}
|
||
</button>
|
||
)}
|
||
|
||
<div className="composer-footer">
|
||
<ModelSelector
|
||
models={models}
|
||
selectedModelId={selectedModelId}
|
||
disabled={busy || readonly}
|
||
onSelect={onSelectModel}
|
||
/>
|
||
<CharacterRemaining
|
||
id={composerRemainingId}
|
||
length={draft.length}
|
||
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<RectificationBoard
|
||
result={candidateResult}
|
||
declaredTime={declaredTime}
|
||
savedStatus={savedStatus}
|
||
diff={boardDiff}
|
||
compact={compactBoard}
|
||
open={boardOpen}
|
||
boardId={boardId}
|
||
titleId={boardTitleId}
|
||
onClose={closeBoard}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|