- Evidence turns whose batch accepted items: the body is the server recap built from those items (two or fewer listed inline, more as a count); the model no longer restates them (both prompt sets updated). - The case snapshot carries each assistant turn's recorded evidence from the ledger (rejected/superseded excluded); the message shows it in a collapsed <details> list when more than two. - Chinese date labels read straight into the event phrase (2016年入学); ISO labels keep their space. - Assertions updated with original/new/reason notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { memo, type MutableRefObject } from "react";
|
||
|
||
import {
|
||
ChatMessageActions,
|
||
type ChatMessageFeedback,
|
||
} from "@/components/chat-message-actions";
|
||
import { ChatMessageRow } from "@/components/chat-message-row";
|
||
import { RectificationChoiceCard } from "@/components/rectification-choice-card";
|
||
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
|
||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||
import {
|
||
noteSettledRowRender,
|
||
noteStreamingRowRender,
|
||
noteUnsplitListRender,
|
||
} from "@/lib/home-streaming-render-probe";
|
||
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
|
||
import {
|
||
CHOICE_MODE,
|
||
CHOICE_SKIP_QUESTION_LABEL,
|
||
CHOICE_SKIP_QUESTION_MESSAGE,
|
||
CHOICE_STOP_LABEL,
|
||
CHOICE_STOP_MESSAGE,
|
||
type ChoiceKey,
|
||
type RectificationChoiceCard as ChoiceCardModel,
|
||
} from "@/lib/rectification-agentic/v9/choice-card";
|
||
import {
|
||
questionIsAnswered,
|
||
questionIsDeadUnanswered,
|
||
type TurnQuestion,
|
||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||
import { RECTIFICATION_STOPPED_NOTICE } from "@/lib/rectification-surface-state";
|
||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||
import { SIGN_LABELS, type SegmentSummary } from "@/lib/rectification-agentic/core/segment-summary";
|
||
|
||
export type RenderMessage = ChatMessageView & {
|
||
renderKey: string;
|
||
completedReceipt?: CompletedActivityReceiptView;
|
||
failed?: boolean;
|
||
stopped?: boolean;
|
||
turnId?: string;
|
||
activityTrace?: readonly AgentActivityTraceItem[];
|
||
question?: TurnQuestion;
|
||
candidateOffer?: Readonly<{ resultId: string }>;
|
||
segmentConsistency?: SegmentSummary;
|
||
/** BUG-1136: what this evidence turn recorded, from the ledger. */
|
||
recordedEvidence?: readonly string[];
|
||
};
|
||
|
||
export type RectificationMessageActions = Readonly<{
|
||
submitChoice: (key: ChoiceKey) => void;
|
||
submitStop: () => void;
|
||
copyMessage: (message: RenderMessage) => void;
|
||
onFeedback: (renderKey: string, requested: ChatMessageFeedback) => void;
|
||
}>;
|
||
|
||
export type RectificationMessageEntryProps = Readonly<{
|
||
message: RenderMessage;
|
||
busy: boolean;
|
||
readonly: boolean;
|
||
currentQuestionFocusId: string | null;
|
||
interactive: boolean;
|
||
liveChoiceCard: ChoiceCardModel | null;
|
||
choiceNonce: number;
|
||
savedTime: string | null;
|
||
copied: boolean;
|
||
feedback: ChatMessageFeedback | undefined;
|
||
actionsRef: MutableRefObject<RectificationMessageActions>;
|
||
}>;
|
||
|
||
export function choiceCardFromQuestion(
|
||
question: TurnQuestion,
|
||
live: ChoiceCardModel | null,
|
||
): ChoiceCardModel | null {
|
||
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
|
||
return { ...live, prompt: question.prompt };
|
||
}
|
||
if (!question.options || question.options.length !== 4) return null;
|
||
return {
|
||
question_id: question.question_id,
|
||
method_id: "",
|
||
prompt: question.prompt,
|
||
why: "",
|
||
varga: null,
|
||
choice_mode: CHOICE_MODE,
|
||
options: question.options.map((option) => ({
|
||
key: option.key,
|
||
label: option.label,
|
||
answer_class: "unsure",
|
||
role: "primary",
|
||
})),
|
||
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
|
||
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
|
||
scoring: question.kind !== "reverse_verify",
|
||
probe_id: question.probe_id,
|
||
case_revision: null,
|
||
focus_id: question.focus_id,
|
||
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
|
||
};
|
||
}
|
||
|
||
function RectificationMessageEntryView({
|
||
message,
|
||
busy,
|
||
currentQuestionFocusId,
|
||
interactive,
|
||
liveChoiceCard,
|
||
choiceNonce,
|
||
savedTime,
|
||
copied,
|
||
feedback,
|
||
actionsRef,
|
||
}: RectificationMessageEntryProps) {
|
||
const showActions = message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& !message.failed
|
||
&& Boolean(message.text);
|
||
// BUG-1137 (D3): the step rows are the waiting state while the reply is
|
||
// written; once it settles the rectification reply carries no
|
||
// 「已完成 N 步」 receipt — those steps are the system's own bookkeeping.
|
||
// Consultation replies keep theirs (ChatMessageRow is unchanged).
|
||
const settled = message.state === "settled";
|
||
const displayedMessage: RenderMessage = {
|
||
...message,
|
||
timeline: settled ? [] : rectificationTimelineRows({
|
||
trace: message.activityTrace,
|
||
receipt: message.completedReceipt,
|
||
activity: message.activity,
|
||
settled,
|
||
}),
|
||
};
|
||
const vargaSentence = !message.failed && !settled
|
||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||
: null;
|
||
const question = message.question;
|
||
const liveQuestion = Boolean(
|
||
question
|
||
&& currentQuestionFocusId === question.focus_id
|
||
&& !questionIsAnswered(question)
|
||
&& interactive
|
||
&& (
|
||
question.kind === "collect_spoken"
|
||
|| (
|
||
liveChoiceCard
|
||
&& liveChoiceCard.focus_id === question.focus_id
|
||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||
)
|
||
),
|
||
);
|
||
// A superseded focus the reader never answered has no answer to show either:
|
||
// its options would render greyed and unclickable (BUG-917).
|
||
const unansweredDeadChoice = Boolean(
|
||
question
|
||
&& (!questionIsAnswered(question) || (question.status === "superseded" && !question.answer_option))
|
||
&& !liveQuestion
|
||
&& (question.kind === "choice" || question.kind === "reverse_verify")
|
||
);
|
||
const embeddedCard = question && !unansweredDeadChoice
|
||
? choiceCardFromQuestion(question, liveQuestion ? liveChoiceCard : null)
|
||
: null;
|
||
// BUG-1135 (D1, supersedes BUG-917's 「stem without options」): a choice
|
||
// nobody can answer any more draws nothing on its message — only the live
|
||
// focus prints a stem, so the same stem never shows twice.
|
||
const replacedQuestion = Boolean(
|
||
question
|
||
&& unansweredDeadChoice
|
||
&& (questionIsDeadUnanswered(question)
|
||
|| (currentQuestionFocusId !== null && currentQuestionFocusId !== question.focus_id)),
|
||
);
|
||
// BUG-1136 (D2): more than two recorded items read as 「记下了 N 件事」 in
|
||
// the body; the items themselves (ledger lines) sit here, collapsed.
|
||
const recorded = settled && (message.recordedEvidence?.length ?? 0) > 2
|
||
? message.recordedEvidence!
|
||
: null;
|
||
const recordedList = recorded ? (
|
||
<details className="rectification-recorded-evidence">
|
||
<summary>{`看记下的 ${recorded.length} 件`}</summary>
|
||
<ul>
|
||
{recorded.map((line, index) => <li key={`${index}:${line}`}>{line}</li>)}
|
||
</ul>
|
||
</details>
|
||
) : null;
|
||
const questionBlock = question && displayedMessage.state === "settled" && !replacedQuestion
|
||
? (
|
||
<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={(key) => actionsRef.current.submitChoice(key)}
|
||
onStop={() => actionsRef.current.submitStop()}
|
||
/>
|
||
)}
|
||
{question.status === "skipped" && (
|
||
<p className="rectification-question-skipped" role="status">
|
||
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
: null;
|
||
const afterAnswer = recordedList || questionBlock
|
||
? <>{recordedList}{questionBlock}</>
|
||
: undefined;
|
||
|
||
return (
|
||
<>
|
||
{(!message.failed || Boolean(displayedMessage.text)) && (
|
||
<ChatMessageRow
|
||
message={displayedMessage}
|
||
showActivity={displayedMessage.state !== "settled"}
|
||
vargaSentence={vargaSentence}
|
||
afterAnswer={afterAnswer}
|
||
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
|
||
/>
|
||
)}
|
||
{message.segmentConsistency?.no_rectification_needed && (
|
||
<section className="rectification-candidates rectification-range-delivery" aria-label="盘型一致,不用校正">
|
||
<strong>盘型一致,不用校正</strong>
|
||
<ul className="rectification-range-delivery__shared">
|
||
{message.segmentConsistency.charts.map((chart) => (
|
||
<li key={chart.chart}>{chart.chart} 上升:{chart.sign === null ? "未核验" : SIGN_LABELS[chart.sign]}(全窗一致)</li>
|
||
))}
|
||
</ul>
|
||
<p className="rectification-range-delivery__boundary">完整出生窗口内目标盘型一致;不等于确认了某一分钟。</p>
|
||
</section>
|
||
)}
|
||
{/* BUG-1056: copy and feedback only; the rectification reply has no rewrite action. */}
|
||
{showActions && (
|
||
<ChatMessageActions
|
||
feedback={feedback}
|
||
copied={copied}
|
||
onFeedback={(requested) => actionsRef.current.onFeedback(message.renderKey, requested)}
|
||
onCopy={() => void actionsRef.current.copyMessage(message)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export const RectificationMessageEntry = memo(function RectificationMessageEntry(
|
||
props: RectificationMessageEntryProps,
|
||
) {
|
||
if (props.message.state !== "settled") noteStreamingRowRender();
|
||
else noteSettledRowRender();
|
||
return <RectificationMessageEntryView {...props} />;
|
||
});
|
||
|
||
export function UnsplitRectificationMessageList({
|
||
messages,
|
||
copiedMessageKey,
|
||
feedbackByKey,
|
||
...shared
|
||
}: Omit<RectificationMessageEntryProps, "message" | "copied" | "feedback"> & {
|
||
messages: readonly RenderMessage[];
|
||
copiedMessageKey: string | null;
|
||
feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>>;
|
||
}) {
|
||
noteUnsplitListRender();
|
||
return (
|
||
<>
|
||
{messages.map((message) => {
|
||
noteSettledRowRender();
|
||
if (message.state !== "settled") noteStreamingRowRender();
|
||
return (
|
||
<RectificationMessageEntryView
|
||
key={message.renderKey}
|
||
{...shared}
|
||
message={message}
|
||
copied={copiedMessageKey === message.renderKey}
|
||
feedback={feedbackByKey[message.renderKey]}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|