Files
Jyotisha/frontend/src/components/rectification-message-entry.tsx
T
jesse-uxandGrok (agent loop) d2296c2654 把前后端共用的请求契约和小工具收成一份,避免再各抄一份。
报告创建请求、校正入口和同名类型改从 contracts 推导;同义的对象判断与 sha256 改走共享函数。调用处用 import 别名保留原名,使 diff 低于 1500 行。行为、哈希和状态字符串不变。

Co-Authored-By: Grok (agent loop)
2026-10-15 22:22:44 +08:00

285 lines
10 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 { 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 RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { ChoiceKey } 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]}
/>
);
})}
</>
);
}