Files
Jyotisha/frontend/src/lib/rectification-chat-turn-run.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
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
2026-09-26 19:23:52 +08:00

511 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}