Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_Chen 9873ba420a fix(rectification): collect dated events, then distinguish with conflict probes
Empty ledgers stay in natural-language collection. After the first dated
event, dasha conflict probes reverse-infer 前事 and block offer until
answered. Unique-minute confirmation stays closed at a representative
time; adopt reverse-verifies remaining probes. Records BUG-348–351.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 10:37:40 +08:00

969 lines
37 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 { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
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 { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
parseRectificationChoiceCard,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
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 { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
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;
}>;
type CandidateResult = RectificationCandidateResult | 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>
<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;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
turnId?: string;
};
function turnOfferedSelection(message: RenderMessage): boolean {
return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates"));
}
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);
return [{
role: "assistant",
text: failed ? "" : turn.text ?? "",
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
failed,
turnId: turn.id,
}];
}
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,
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 [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 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 [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
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]);
useEffect(() => {
const container = conversation.current;
if (!container) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({
top: container.scrollHeight,
behavior: busy || reduceMotion ? "auto" : "smooth",
});
}, [busy, candidateResult, choiceCard, error, messages, savedTime]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
choice_card?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
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);
setChoiceCard(nextChoice);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
} else if (acceptedTime) {
setSavedTime(acceptedTime);
setSavedStatus("accepted");
}
}, []);
const loadCaseSnapshot = useCallback(async () => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return;
applyCaseSnapshot(await response.json().catch(() => null));
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
}
}, [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) 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", 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}`;
setMessages((current) => [
...current,
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
} },
]);
setDraft("");
let raw = "";
let thinkingRaw = "";
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
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 } : {}),
}),
});
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;
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;
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: 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.text;
const parsed = parseAgentReply(raw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: parsed.text,
state: "streaming",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message));
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
thinkingRaw += event.text;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
thinkingText: thinkingRaw,
state: raw ? "streaming" : "thinking",
}
: message));
} else if (event.type === "attempt.reset") {
raw = "";
thinkingRaw = "";
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking",
completedReceipt: undefined,
failed: false,
turnId: undefined,
activity: nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
}),
}
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
} 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") {
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
status: event.status,
methods: event.status === "completed" && Array.isArray(event.methods)
? event.methods.filter(isPublicRectificationMethod)
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
}
}
}
const parsed = parseAgentReply(raw);
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,
state: "settled",
completedReceipt,
failed: false,
turnId: completedTurnId,
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
return [{
...message,
text: parsed.text,
state: "settled",
completedReceipt,
failed: true,
activity: undefined,
}];
}
return [];
}));
if (!succeeded && parsed.text) {
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
} else if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
}
if (succeeded) {
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
await loadCaseSnapshot();
}
} catch (caught) {
const aborted = caught instanceof DOMException
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
const parsed = parseAgentReply(raw);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (parsed.text) {
return [{
...message,
text: parsed.text,
state: "settled",
completedReceipt,
failed: false,
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 {
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || readonly) return;
setError("");
setAcceptingCandidateId(candidateId);
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();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingCandidateId(null);
}
}, [acceptingCandidateId, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId]);
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(message.text);
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 || "暂时无法重新生成回答");
}
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: payload.assistantMessage, 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 offeredSelectionOnce = messages.some(turnOfferedSelection);
const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant));
const showChoiceCards = Boolean(
choiceCard
&& latestSettledAssistant
&& !offeredThisTurn
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
&& latestSettledAssistant
&& !showChoiceCards
&& !busy
&& regeneratingMessageKey === null,
);
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const choiceCardMessageKey = showChoiceCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void send("message", choiceCardUserMessage(choiceCard, key));
}
function submitStop() {
void send("message", choiceCard?.stop_message ?? CHOICE_STOP_MESSAGE);
}
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;
const displayedMessage = regenerating
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: nextActivityView(undefined, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message;
const vargaSentence = message.state === "settled" && !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.failed && (
<p className="rectification-activity-failure" role="status">
{message.text
? "回答未完成,已保留现有内容;本次不会扣点。"
: "本轮处理未完成,已保留服务端记录的执行进度。"}
</p>
)}
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
/>
)}
{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)}
/>
)}
{showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && (
<RectificationChoiceCard
key={choiceCard.question_id}
card={choiceCard}
pending={busy}
disabled={readonly}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
</div>
);
})}
{savedTime && (
<div className="rectification-saved-wrap">
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性时间,本会话不确认唯一分钟)"}{savedTime}使
</p>
{savedStatus === "accepted" && onStartConsultation && (
<div className="rectification-consult-handoff">
<Button type="button" onClick={onStartConsultation}>
</Button>
</div>
)}
</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">
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
value={draft}
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showChoiceCards
? "点上面的选项即可;想补一句细节再写"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
{busy ? (
<Button
className="composer-stop"
aria-label="停止回答"
title="停止当前推理,已生成内容会保留"
size="icon"
type="button"
onClick={stopRun}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
</div>
</div>
</div>
<RectificationBoard
result={candidateResult}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}