Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_ChenandCursor 8dfe457f21
Independent Staging Quality Gate / validate (push) Successful in 9m30s
Independent Staging Quality Gate / publish (push) Successful in 1m50s
fix(rectification): keep choice options and scoring on the server
Agent set-focus was writing or dropping choice schema, so probes never scored. Force server-owned options, fail closed when focus list RPC errors, and require the probe year in spokenPrompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 20:47:16 +08:00

1410 lines
53 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.
"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,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
canRenderRectificationSelectionCards,
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 { 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_STOP_LABEL, 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 { CharacterRemaining } from "./character-remaining";
import { ChatComposer } from "./chat-composer";
import { ChatMessageRow } from "./chat-message-row";
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;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
}>[];
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") return null;
return {
kind: question.kind,
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 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[];
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;
onStartConsultation?: () => 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;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, TurnQuestion | null>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, parseTurnQuestion(turn.question));
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const question = byId.get(message.turnId) ?? undefined;
return { ...message, question: question ?? undefined };
});
}
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: CHOICE_STOP_LABEL,
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,
}];
}
if (isIncompleteRunBanner(turn.text ?? "")) return [];
if (isStructuredChoiceUserText(turn.text)) return [];
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
readonly,
shouldStartOpening,
initialTurns,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
onPendingChange,
onProfileIncomplete,
onSaved,
onStartConsultation,
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>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = 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 choiceContinuationPending = useRef(false);
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 choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const composerRemainingId = useId();
// The same anchor-and-follow the consultation surface uses: streamed tokens and
// new cards land the viewport on the bottom only while the reader is there.
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => {
currentQuestionRef.current = currentQuestion;
}, [currentQuestion]);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
const update = () => {
const nextCompact = query.matches;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
const stopRun = useCallback(() => {
runAbort.current?.abort();
}, []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
onPendingChange?.(value);
}, [onPendingChange]);
// 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);
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> => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
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;
}
}, [applyCaseSnapshot, caseId, sessionId]);
useEffect(() => {
const controller = new AbortController();
void fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store", signal: controller.signal },
)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (controller.signal.aborted) return;
applyCaseSnapshot(payload);
})
.catch(() => {
// Snapshot refresh is best-effort; the durable Case remains on the server.
});
return () => controller.abort();
}, [applyCaseSnapshot, caseId, sessionId]);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setPending(true);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const turnKey = keyCounter.current;
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
const pendingFocusId = currentQuestionRef.current?.focus_id;
setMessages((current) => [
...(action === "message" && pendingFocusId
? markQuestionAnswered(current, pendingFocusId, "typed")
: current),
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
} },
]);
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: "正在处理…",
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) {
window.location.assign(membershipHref("rectification"));
return;
}
if (response.status === 401) setError("请先登录。");
else setError(message);
return;
}
if (!response.body) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError("服务暂时不可用,请稍后再试。");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
let streamFailed = false;
let runFailedMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: {
type?: unknown;
status?: unknown;
text?: unknown;
replace?: unknown;
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
code?: unknown;
activity?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
} catch {
continue;
}
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw = event.replace === true ? event.text : raw + event.text;
activityTrace = freezeLiveThink(activityTrace);
currentActivity = nextActivityView(currentActivity, {
phase: "answer-composition",
label: "正在组织回答…",
});
frames.setAnswer(raw);
} else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) {
const activity = event.activity;
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: 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: "正在处理…",
});
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: RECTIFICATION_TOOL_PROGRESS_LABELS[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: RECTIFICATION_TOOL_DONE_LABELS[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 [];
}));
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);
}
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, 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",
) => {
if (!choiceCard || busy || readonly) return;
const focusId = choiceCard.focus_id;
if (!isPersistedFocusId(focusId)) {
setError("当前选择题已失效,请等待下一问。");
return;
}
const questionId = choiceCard.question_id;
const actionId = actionIdForChoice(focusId, optionId);
setError("");
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
{
role: "assistant",
text: "",
renderKey: assistantRenderKey,
state: "thinking",
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
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: choiceCard.probe_id,
optionId: optionId === "stop" ? undefined : optionId,
expectedRevision: choiceCard.case_revision ?? 0,
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) {
setMessages((current) => mergeTurnQuestions(
current.filter((message) => message.renderKey !== assistantRenderKey),
turns,
));
choiceContinuationPending.current = true;
} 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);
}
}, [
actionIdForChoice,
busy,
caseId,
choiceCard,
loadCaseSnapshot,
onCompleted,
onMessagesChange,
onProfileIncomplete,
readonly,
sessionId,
setPending,
]);
useEffect(() => {
if (!choiceContinuationPending.current || busy || readonly) return;
choiceContinuationPending.current = false;
void send("read_only", "");
}, [busy, readonly, send]);
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);
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();
choiceContinuationPending.current = true;
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingCandidateId(null);
setPending(false);
}
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, 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
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& candidateResult?.canAdopt
&& canRenderRectificationSelectionCards(candidateResult)
&& latestSettledAssistant
&& !showLiveChoiceCard
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: 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)
));
const questionLoadFailed = questionSource === "unavailable";
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion === null
&& !questionLoadFailed,
);
const showUnavailableQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion !== null
&& !liveQuestionOnMessages
&& !questionLoadFailed,
);
const showQuestionLoadFailed = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& questionLoadFailed,
);
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void submitStructuredChoice(CHOICE_ACTION, key);
}
function submitStop() {
if (!choiceCard) return;
void submitStructuredChoice(STOP_ACTION, "stop");
}
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={toggleBoard}
/>
) : null;
return (
<div
ref={workspace}
className={`rectification-workspace${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>
)}
{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)
&& choiceCard
&& choiceCard.focus_id === question.focus_id
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
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}
/>
)}
</div>
)
: undefined;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{(!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}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
</div>
);
})}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
已确认校正时间:{savedTime}
</p>
)}
{savedTime && savedStatus === "accepted" && onStartConsultation && (
<div className="rectification-consult-handoff">
<Button type="button" onClick={onStartConsultation}>
用这个时间看盘
</Button>
</div>
)}
{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} />
)}
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
<p className="rectification-composer-status" role="status">
{showQuestionLoadFailed
? "题目加载失败,请刷新。"
: showMissingQuestion
? "当前没有可回答的问题,正在等待服务端更新。"
: "当前问题暂时无法显示,请等待服务端更新。"}
</p>
)}
<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}
/>
<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}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}