661a0dab14
Persist the interview slot before the opening turn, keep the stem in the question slot, and hide that slot while a run is busy so the same ask does not appear twice. Co-authored-by: Cursor <cursoragent@cursor.com>
1346 lines
51 KiB
TypeScript
1346 lines
51 KiB
TypeScript
"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 {
|
||
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 { Button } from "./ui/button";
|
||
|
||
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;
|
||
}>;
|
||
|
||
/** 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;
|
||
}>;
|
||
|
||
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||
const question = value as { kind?: unknown; prompt?: 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,
|
||
};
|
||
}
|
||
|
||
|
||
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 SettledChoiceAttachment = Readonly<{
|
||
card: ChoiceCardModel;
|
||
selectedKey: ChoiceKey | "stop";
|
||
}>;
|
||
|
||
type RenderMessage = ChatMessageView & {
|
||
renderKey: string;
|
||
completedReceipt?: CompletedActivityReceiptView;
|
||
failed?: boolean;
|
||
turnId?: string;
|
||
activityTrace?: readonly AgentActivityTraceItem[];
|
||
choiceAttachment?: SettledChoiceAttachment;
|
||
};
|
||
|
||
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,
|
||
}];
|
||
}
|
||
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 [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 [compactBoard, setCompactBoard] = useState(false);
|
||
const [boardOpen, setBoardOpen] = useState(false);
|
||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||
const boardId = useId();
|
||
const boardTitleId = useId();
|
||
const collectSpokenPromptId = 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(() => {
|
||
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;
|
||
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);
|
||
setChoiceCard(nextChoice);
|
||
setCaseStatus(nextCaseStatus);
|
||
setCaseSnapshotLoaded(true);
|
||
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" | "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}`;
|
||
|
||
setMessages((current) => [
|
||
...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) {
|
||
onMessagesChange?.([
|
||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||
{ role: "assistant", text: parsed.text },
|
||
]);
|
||
onCompleted?.();
|
||
await loadCaseSnapshot();
|
||
}
|
||
} 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);
|
||
const answeredCard = choiceCard;
|
||
const offeringRenderKey = [...messages].reverse().find((message) => (
|
||
message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& Boolean(message.text)
|
||
&& !message.choiceAttachment
|
||
))?.renderKey ?? null;
|
||
setError("");
|
||
keyCounter.current += 1;
|
||
const turnKey = keyCounter.current;
|
||
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
||
setMessages((current) => [
|
||
...current.map((message) => (
|
||
offeringRenderKey && message.renderKey === offeringRenderKey
|
||
? {
|
||
...message,
|
||
choiceAttachment: { card: answeredCard, selectedKey: optionId },
|
||
}
|
||
: message
|
||
)),
|
||
{
|
||
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.flatMap((message) => {
|
||
if (message.renderKey === assistantRenderKey) return [];
|
||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||
return [{ ...message, choiceAttachment: undefined }];
|
||
}
|
||
return [message];
|
||
}));
|
||
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?.();
|
||
await loadCaseSnapshot();
|
||
if (willContinue) {
|
||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||
choiceContinuationPending.current = true;
|
||
} else {
|
||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||
? payload.narration.trim()
|
||
: "已记录你的选择。";
|
||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||
? {
|
||
...message,
|
||
text: narration,
|
||
state: "settled" as const,
|
||
activity: undefined,
|
||
}
|
||
: message));
|
||
onMessagesChange?.([
|
||
{ role: "assistant", text: narration },
|
||
]);
|
||
}
|
||
} catch {
|
||
setMessages((current) => current.flatMap((message) => {
|
||
if (message.renderKey === assistantRenderKey) return [];
|
||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||
return [{ ...message, choiceAttachment: undefined }];
|
||
}
|
||
return [message];
|
||
}));
|
||
setChoiceNonce((current) => current + 1);
|
||
setError("选择题处理失败,请稍后重试。");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}, [
|
||
actionIdForChoice,
|
||
busy,
|
||
caseId,
|
||
choiceCard,
|
||
loadCaseSnapshot,
|
||
messages,
|
||
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(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 answeredQuestionIds = new Set(
|
||
messages.flatMap((message) => message.choiceAttachment
|
||
? [message.choiceAttachment.card.question_id]
|
||
: []),
|
||
);
|
||
const showLiveChoiceCard = Boolean(
|
||
currentQuestion?.kind === "choice"
|
||
&& choiceCard
|
||
&& !answeredQuestionIds.has(choiceCard.question_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 showCollectSpokenPrompt = Boolean(
|
||
collectSpokenPrompt
|
||
&& !showLiveChoiceCard
|
||
&& !readonly
|
||
&& !busy
|
||
&& regeneratingMessageKey === null,
|
||
);
|
||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||
const showMissingQuestion = Boolean(
|
||
caseSnapshotLoaded
|
||
&& resumableCase
|
||
&& !readonly
|
||
&& !busy
|
||
&& currentQuestion === null,
|
||
);
|
||
const showUnavailableQuestion = Boolean(
|
||
caseSnapshotLoaded
|
||
&& resumableCase
|
||
&& !readonly
|
||
&& !busy
|
||
&& currentQuestion !== null
|
||
&& ((currentQuestion.kind === "choice" && !choiceCard)
|
||
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
|
||
);
|
||
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 settledChoice = message.choiceAttachment;
|
||
const visibleChoiceCard = settledChoice?.card;
|
||
const vargaSentence = !message.failed
|
||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||
: null;
|
||
const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey;
|
||
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}
|
||
/>
|
||
)}
|
||
{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)}
|
||
/>
|
||
)}
|
||
{visibleChoiceCard && (
|
||
<RectificationChoiceCard
|
||
key={settledChoice
|
||
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
|
||
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
|
||
card={visibleChoiceCard}
|
||
pending={false}
|
||
disabled={readonly || Boolean(settledChoice)}
|
||
selectedKey={settledChoice?.selectedKey ?? ""}
|
||
onSelect={submitChoice}
|
||
onStop={submitStop}
|
||
/>
|
||
)}
|
||
{isLatestMessage && (
|
||
<section className="rectification-question-slot" aria-label="当前问题">
|
||
{showLiveChoiceCard && choiceCard && (
|
||
<RectificationChoiceCard
|
||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||
card={choiceCard}
|
||
pending={busy}
|
||
disabled={readonly}
|
||
selectedKey=""
|
||
onSelect={submitChoice}
|
||
onStop={submitStop}
|
||
/>
|
||
)}
|
||
{showCollectSpokenPrompt && (
|
||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||
{collectSpokenPrompt}
|
||
</p>
|
||
)}
|
||
{showMissingQuestion && (
|
||
<p className="rectification-question-slot__status" role="status">
|
||
当前没有可回答的问题,正在等待服务端更新。
|
||
</p>
|
||
)}
|
||
{showUnavailableQuestion && (
|
||
<p className="rectification-question-slot__status" role="status">
|
||
当前问题暂时无法显示,请等待服务端更新。
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{messages.length === 0 && (
|
||
<section className="rectification-question-slot" aria-label="当前问题">
|
||
{showLiveChoiceCard && choiceCard && (
|
||
<RectificationChoiceCard
|
||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||
card={choiceCard}
|
||
pending={busy}
|
||
disabled={readonly}
|
||
selectedKey=""
|
||
onSelect={submitChoice}
|
||
onStop={submitStop}
|
||
/>
|
||
)}
|
||
{showCollectSpokenPrompt && (
|
||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||
{collectSpokenPrompt}
|
||
</p>
|
||
)}
|
||
{showMissingQuestion && (
|
||
<p className="rectification-question-slot__status" role="status">
|
||
当前没有可回答的问题,正在等待服务端更新。
|
||
</p>
|
||
)}
|
||
{showUnavailableQuestion && (
|
||
<p className="rectification-question-slot__status" role="status">
|
||
当前问题暂时无法显示,请等待服务端更新。
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
{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} />
|
||
)}
|
||
<ChatComposer
|
||
inputRef={composer}
|
||
describedBy={showCollectSpokenPrompt
|
||
? collectSpokenPromptId
|
||
: undefined}
|
||
value={draft}
|
||
remainingId={composerRemainingId}
|
||
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||
placeholder={readonly
|
||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||
: showLiveChoiceCard
|
||
? "点上面的选项即可;想补一句细节再写"
|
||
: showCollectSpokenPrompt
|
||
? "请回答上面的问题…"
|
||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||
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>
|
||
);
|
||
}
|