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
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
/**
|
||
* One rectification agent turn from the browser: the POST to
|
||
* /api/rectification/agent, the NDJSON stream read into the live row, the
|
||
* settle / fail / stop branches, and the snapshot re-read after the turn.
|
||
* Moved out of RectificationAgenticChat's `send` 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 { 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,
|
||
RECTIFICATION_TURN_PROGRESS_LABELS,
|
||
rectificationCompletedTrail,
|
||
rectificationToolActivityPhase,
|
||
rectificationTurnProgressPhase,
|
||
} from "@/lib/rectification-activity-labels";
|
||
import { isRectificationTurnProgressStage } from "@/lib/rectification-agentic/v9/turn-progress";
|
||
import { withLiveStepLabel } from "@/lib/rectification-timeline-adapter";
|
||
import {
|
||
createRectificationActivityReceiptState,
|
||
receiptFromRectificationActivityState,
|
||
reduceRectificationActivityReceipt,
|
||
} from "@/lib/rectification-activity-receipt";
|
||
import {
|
||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||
isAbortError,
|
||
rectificationInitialLiveLabel,
|
||
} from "@/lib/rectification-surface-state";
|
||
import {
|
||
isPublicRectificationActivity,
|
||
isPublicRectificationMethod,
|
||
isPublicRectificationTool,
|
||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||
import { userFacingRunFailure } from "@/lib/rectification-agentic/v9/run-diagnostic";
|
||
import {
|
||
applySnapshotTurnsToMessages,
|
||
mergeTurnQuestions,
|
||
} from "@/lib/rectification-snapshot-messages";
|
||
import {
|
||
hasActivityReceipt,
|
||
markQuestionAnswered,
|
||
messagesFromTurns,
|
||
withdrawAnswerMark,
|
||
type PersistedTurn,
|
||
} from "@/lib/rectification-chat-messages";
|
||
import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot";
|
||
import type { RenderMessage } from "@/components/rectification-message-entry";
|
||
|
||
export type RectificationTurnAction = "opening" | "message" | "read_only";
|
||
export type RectificationTurnContinuation = Readonly<{ reuseAssistantRenderKey: string; label: string }>;
|
||
export type RectificationRunOutcome = "succeeded" | "stopped" | "failed" | "readonly" | null;
|
||
export type RectificationSnapshotLoader = (
|
||
mergeMessages?: (turns: readonly unknown[]) => void,
|
||
) => Promise<unknown>;
|
||
|
||
export type RectificationChatTurnDeps = Readonly<{
|
||
caseId: string;
|
||
sessionId: string;
|
||
selectedModelId: string;
|
||
busy: boolean;
|
||
readonly: boolean;
|
||
conversationReadonly: boolean;
|
||
keyCounter: MutableRefObject<number>;
|
||
currentQuestionRef: MutableRefObject<CurrentQuestionModel | null>;
|
||
runAbort: MutableRefObject<AbortController | null>;
|
||
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
|
||
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
|
||
setError: Dispatch<SetStateAction<string>>;
|
||
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
|
||
setPending: (value: boolean) => void;
|
||
beginLiveRun: (baseLabel: string) => void;
|
||
rememberLiveActivity: (baseLabel: string, tool?: string | null) => string;
|
||
loadCaseSnapshot: RectificationSnapshotLoader;
|
||
onCompleted?: () => void;
|
||
onMessagesChange?: (messages: ChatMessage[]) => void;
|
||
onOpenBilling?: (options?: { source?: string }) => void;
|
||
onProfileIncomplete?: () => void;
|
||
}>;
|
||
|
||
/**
|
||
* 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`.
|
||
*/
|
||
export async function runRectificationChatTurn(
|
||
deps: RectificationChatTurnDeps,
|
||
action: RectificationTurnAction,
|
||
messageText: string,
|
||
continuation?: RectificationTurnContinuation,
|
||
): Promise<void> {
|
||
const {
|
||
caseId,
|
||
sessionId,
|
||
selectedModelId,
|
||
busy,
|
||
readonly,
|
||
conversationReadonly,
|
||
keyCounter,
|
||
currentQuestionRef,
|
||
runAbort,
|
||
lastRunOutcome,
|
||
conversationAnchor,
|
||
setError,
|
||
setMessages,
|
||
setPending,
|
||
beginLiveRun,
|
||
rememberLiveActivity,
|
||
loadCaseSnapshot,
|
||
onCompleted,
|
||
onMessagesChange,
|
||
onOpenBilling,
|
||
onProfileIncomplete,
|
||
} = deps;
|
||
const trimmed = action === "message" ? messageText.trim() : "";
|
||
if ((action === "message" && !trimmed) || conversationReadonly) 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,
|
||
]));
|
||
if (!continuation) conversationAnchor.pinLatestTurn();
|
||
// The typed answer never reached the server: the question it marked is
|
||
// still the pending one (BUG-1046, same as a failed tap).
|
||
const withdrawTypedMark = (current: RenderMessage[]) => (
|
||
!continuation && action === "message" && pendingFocusId
|
||
? withdrawAnswerMark(current, pendingFocusId, "typed")
|
||
: current
|
||
);
|
||
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,
|
||
}),
|
||
});
|
||
// A turn the server turned down: the old non-2xx response, or the same
|
||
// facts as a `turn.rejected` event once the stream is already open
|
||
// (BUG-1047). Either way the live row goes and the typed mark returns.
|
||
const rejectTurn = (
|
||
response: { status: number },
|
||
payload: { code?: unknown; message?: unknown; error?: unknown } | null,
|
||
) => {
|
||
const message = (typeof payload?.message === "string" && payload.message)
|
||
|| (typeof payload?.error === "string" && payload.error)
|
||
|| `请求失败(${response.status})`;
|
||
setMessages((current) => withdrawTypedMark(
|
||
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);
|
||
};
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => null);
|
||
rejectTurn(response, payload);
|
||
return;
|
||
}
|
||
if (!response.body) {
|
||
setMessages((current) => withdrawTypedMark(
|
||
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 = "";
|
||
let rejected: { status: number; payload: { code?: unknown; message?: unknown } } | null = null;
|
||
// Stage line of a typed answer (BUG-1047 D3); null until the first
|
||
// `turn.progress`. It replaces the generic tool / "正在分析…" labels.
|
||
let stageLabel: string | null = null;
|
||
while (!rejected) {
|
||
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;
|
||
stage?: unknown;
|
||
httpStatus?: unknown;
|
||
};
|
||
try {
|
||
event = JSON.parse(line) as typeof event;
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (typeof event.type !== "string") continue;
|
||
if (event.type === "turn.rejected" && typeof event.httpStatus === "number") {
|
||
rejected = { status: event.httpStatus, payload: { code: event.code, message: event.message } };
|
||
break;
|
||
}
|
||
if (event.type === "turn.progress" && isRectificationTurnProgressStage(event.stage)) {
|
||
stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS[event.stage];
|
||
activityTrace = withLiveStepLabel(activityTrace, stageLabel);
|
||
currentActivity = nextActivityView(currentActivity, {
|
||
phase: rectificationTurnProgressPhase(event.stage),
|
||
label: rememberLiveActivity(stageLabel, null),
|
||
});
|
||
frames.touch();
|
||
} else 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(stageLabel ?? RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
|
||
});
|
||
frames.touch();
|
||
} else if (event.type === "attempt.reset") {
|
||
raw = "";
|
||
activityTrace = emptyActivityTrace();
|
||
activityReceiptState = createRectificationActivityReceiptState();
|
||
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||
completedTurnId = undefined;
|
||
if (stageLabel) stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS.received;
|
||
currentActivity = nextActivityView(undefined, {
|
||
phase: "evidence-validation",
|
||
label: rememberLiveActivity(stageLabel ?? "正在处理…", 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,
|
||
stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
|
||
);
|
||
currentActivity = nextActivityView(currentActivity, {
|
||
phase: rectificationToolActivityPhase(tool),
|
||
label: rememberLiveActivity(stageLabel ?? 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: stageLabel
|
||
? rememberLiveActivity(stageLabel, null)
|
||
: rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null),
|
||
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
|
||
});
|
||
frames.touch();
|
||
}
|
||
}
|
||
}
|
||
frames.settle();
|
||
if (rejected) {
|
||
await reader.cancel().catch(() => undefined);
|
||
rejectTurn({ status: rejected.status }, rejected.payload);
|
||
return;
|
||
}
|
||
|
||
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) {
|
||
// D4 (BUG-1046): a turn the server wrote alongside this one (the
|
||
// one carrying the next question) joins the transcript too.
|
||
setMessages((current) => applySnapshotTurnsToMessages(
|
||
current,
|
||
turns,
|
||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||
));
|
||
}
|
||
});
|
||
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) => withdrawTypedMark(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);
|
||
}
|
||
}
|