Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.
- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
(bodies verbatim, deps destructured to the same names); copy/regenerate,
question repair, pure transcript and snapshot helpers and the per-render
view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
(container + split files, like home-surface.ts); slices of moved code now
call the extracted functions or render the extracted block, each with a
原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/**
|
|
* A tapped choice (answer, stop, skip this probe) from the rectification chat:
|
|
* the local answered mark and live row, the structured POST, the transcript
|
|
* merge after the Case re-read, the follow-up turn on the same row, and the
|
|
* roll-back when the tap did not land (BUG-1046). Moved out of
|
|
* RectificationAgenticChat's `submitStructuredChoice` unchanged
|
|
* (TASK-rectification-code-split-20260926). A parameter function: the
|
|
* component hands in its state, setters and refs; no React hooks here.
|
|
*/
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
import type { ChatMessage } from "@/lib/chat-message-view";
|
|
import { emptyActivityTrace } from "@/lib/agent-activity-trace";
|
|
import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS } from "@/lib/rectification-activity-labels";
|
|
import {
|
|
RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY,
|
|
isAbortError,
|
|
} from "@/lib/rectification-surface-state";
|
|
import {
|
|
CHOICE_ACTION,
|
|
SKIP_PROBE_ACTION,
|
|
STOP_ACTION,
|
|
shouldContinueAfterStructuredChoice,
|
|
type ChoiceOptionId,
|
|
} from "@/lib/rectification-agentic/v9/choice-action";
|
|
import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
|
import {
|
|
appendUnseenAssistantTurns,
|
|
applySnapshotTurnsToMessages,
|
|
mergeTurnQuestions,
|
|
} from "@/lib/rectification-snapshot-messages";
|
|
import {
|
|
markQuestionAnswered,
|
|
messagesFromTurns,
|
|
withdrawAnswerMark,
|
|
type PersistedTurn,
|
|
} from "@/lib/rectification-chat-messages";
|
|
import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot";
|
|
import type {
|
|
RectificationRunOutcome,
|
|
RectificationSnapshotLoader,
|
|
RectificationTurnAction,
|
|
RectificationTurnContinuation,
|
|
} from "@/lib/rectification-chat-turn-run";
|
|
import type { RenderMessage } from "@/components/rectification-message-entry";
|
|
|
|
export type RectificationStructuredChoiceAction = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
|
|
export type RectificationStructuredChoiceOverride = Readonly<{
|
|
focusId?: string;
|
|
questionId?: string | null;
|
|
probeId?: string | null;
|
|
caseRevision?: number | null;
|
|
}>;
|
|
|
|
export type RectificationStructuredChoiceDeps = Readonly<{
|
|
caseId: string;
|
|
sessionId: string;
|
|
busy: boolean;
|
|
readonly: boolean;
|
|
choiceCard: ChoiceCardModel | null;
|
|
currentQuestion: CurrentQuestionModel | null;
|
|
keyCounter: MutableRefObject<number>;
|
|
runAbort: MutableRefObject<AbortController | null>;
|
|
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
|
|
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
|
|
actionIdForChoice: (focusId: string, optionId: ChoiceOptionId) => string;
|
|
setError: Dispatch<SetStateAction<string>>;
|
|
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
|
|
setChoiceNonce: Dispatch<SetStateAction<number>>;
|
|
setNextUserActionId: Dispatch<SetStateAction<string | null>>;
|
|
setPending: (value: boolean) => void;
|
|
beginLiveRun: (baseLabel: string) => void;
|
|
loadCaseSnapshot: RectificationSnapshotLoader;
|
|
send: (
|
|
action: RectificationTurnAction,
|
|
messageText: string,
|
|
continuation?: RectificationTurnContinuation,
|
|
) => Promise<void>;
|
|
onCompleted?: () => void;
|
|
onMessagesChange?: (messages: ChatMessage[]) => void;
|
|
onProfileIncomplete?: () => void;
|
|
}>;
|
|
|
|
export async function runRectificationStructuredChoice(
|
|
deps: RectificationStructuredChoiceDeps,
|
|
action: RectificationStructuredChoiceAction,
|
|
optionId: ChoiceKey | "stop" | "skip_probe",
|
|
override?: RectificationStructuredChoiceOverride,
|
|
): Promise<void> {
|
|
const {
|
|
caseId,
|
|
sessionId,
|
|
busy,
|
|
readonly,
|
|
choiceCard,
|
|
currentQuestion,
|
|
keyCounter,
|
|
runAbort,
|
|
lastRunOutcome,
|
|
conversationAnchor,
|
|
actionIdForChoice,
|
|
setError,
|
|
setMessages,
|
|
setChoiceNonce,
|
|
setNextUserActionId,
|
|
setPending,
|
|
beginLiveRun,
|
|
loadCaseSnapshot,
|
|
send,
|
|
onCompleted,
|
|
onMessagesChange,
|
|
onProfileIncomplete,
|
|
} = deps;
|
|
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(),
|
|
},
|
|
},
|
|
]);
|
|
conversationAnchor.pinLatestTurn();
|
|
setPending(true);
|
|
// BUG-1046 (D2): a tap that did not land takes back its local answered
|
|
// mark and placeholder; the card key changes (answer_option → nonce), so
|
|
// the card remounts clean and one tappable card is left.
|
|
const rollBackFailedChoice = () => {
|
|
setMessages((current) => withdrawAnswerMark(
|
|
current.filter((message) => message.renderKey !== assistantRenderKey),
|
|
focusId ?? "",
|
|
optionId,
|
|
));
|
|
setChoiceNonce((current) => current + 1);
|
|
};
|
|
// The server may have moved on (stale_question, a dropped response after
|
|
// it applied): re-read the Case so the card reflects what it holds.
|
|
const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => {
|
|
setMessages((current) => applySnapshotTurnsToMessages(
|
|
current,
|
|
turns,
|
|
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
|
));
|
|
});
|
|
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) {
|
|
rollBackFailedChoice();
|
|
if (payload?.code === "profile_incomplete") {
|
|
onProfileIncomplete?.();
|
|
return;
|
|
}
|
|
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
|
await reloadAfterFailedChoice();
|
|
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) {
|
|
// D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn
|
|
// the server wrote while applying the tap joins the transcript,
|
|
// above the row the follow-up run continues on.
|
|
setMessages((current) => mergeTurnQuestions(
|
|
appendUnseenAssistantTurns(
|
|
current,
|
|
messagesFromTurns(turns as readonly PersistedTurn[]),
|
|
{ beforeRenderKey: assistantRenderKey },
|
|
),
|
|
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";
|
|
rollBackFailedChoice();
|
|
setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY);
|
|
await reloadAfterFailedChoice();
|
|
} finally {
|
|
if (runAbort.current === abortController) runAbort.current = null;
|
|
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
|
|
setPending(false);
|
|
}
|
|
}
|