fix(rectification): make every gap a live row with retries, give an empty Case a start, and say why a run stopped
The chat used to show "当前没有可回答的问题,正在等待服务端更新" / "当前问题暂时无法显示"
/ "题目加载失败,请刷新" as bare copy above the composer. The gap between a
settled turn and its next question is now a state: `preparing` renders the
same timeline live row the reply uses ("正在准备下一个问题…") and refetches the
Case on a 2s timer up to two retries; `unavailable` renders one line and a
44px reload button. A snapshot that never arrived at hydration is the same
gap. A resumed Case with no turns and no server-started opening shows
"这段校正还没有开始。" with one 开始提问 action. Stopping a run keeps what
streamed and says so instead of "暂时不可用"; the opening turn's live row
names what is happening; a 402 explains itself for 600ms before leaving.
BUG-505, BUG-507
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
53017d4153
commit
360989ca2c
@@ -83,6 +83,20 @@ export function ConsultationRunTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One live step outside a reply's timeline — the rectification surface uses it
|
||||
* for the gap between a settled turn and its next question. It is the same
|
||||
* row as inside the timeline (marker, spinner, shimmer label), not a second
|
||||
* waiting vocabulary.
|
||||
*/
|
||||
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
|
||||
return (
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
|
||||
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
const KindIcon = KIND_ICONS[row.kind];
|
||||
const expandable = Boolean(
|
||||
|
||||
@@ -43,7 +43,22 @@ import {
|
||||
} from "@/lib/rectification-board-model";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import type { RectificationCaseSnapshotPayload } from "@/lib/rectification-surface-state";
|
||||
import {
|
||||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||||
RECTIFICATION_EMPTY_COPY,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
|
||||
RECTIFICATION_QUESTION_PREPARING_LABEL,
|
||||
RECTIFICATION_QUESTION_RELOAD_LABEL,
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_STOPPED_NOTICE,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionGapState,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
@@ -63,9 +78,11 @@ import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseSta
|
||||
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, 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 { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import { CharacterRemaining } from "./character-remaining";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
|
||||
import { JumpToLatestButton } from "./jump-to-latest-button";
|
||||
import {
|
||||
ChatMessageActions,
|
||||
@@ -451,6 +468,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [openingRequested, setOpeningRequested] = 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);
|
||||
@@ -525,6 +544,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
// A turn starting is a fresh chance for the next question to arrive.
|
||||
if (value) setQuestionRetryAttempts(0);
|
||||
onPendingChange?.(value);
|
||||
}, [onPendingChange]);
|
||||
|
||||
@@ -622,6 +643,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
@@ -685,7 +707,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if ((action === "message" && !trimmed) || busy || readonly) return;
|
||||
setError("");
|
||||
setPending(true);
|
||||
beginLiveRun("正在处理…");
|
||||
const initialLabel = rectificationInitialLiveLabel(action);
|
||||
beginLiveRun(initialLabel);
|
||||
|
||||
keyCounter.current += 1;
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
@@ -703,7 +726,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
label: initialLabel,
|
||||
startedAt: Date.now(),
|
||||
} },
|
||||
]);
|
||||
@@ -716,7 +739,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let completedTurnId: string | undefined;
|
||||
let currentActivity: AgentActivityView | undefined = {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
label: initialLabel,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
// Every stream event lands in `frames`; it commits at most once per animation
|
||||
@@ -765,7 +788,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 402) {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
// Say why before the page leaves, instead of vanishing mid-chat.
|
||||
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
|
||||
window.setTimeout(() => {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) setError("请先登录。");
|
||||
@@ -970,6 +997,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
// The reader pressed stop: what streamed stays, and the notice says so
|
||||
// rather than blaming the service.
|
||||
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
|
||||
return;
|
||||
}
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
@@ -1328,33 +1358,63 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& message.question.focus_id === currentQuestion.focus_id
|
||||
&& !questionIsAnswered(message.question)
|
||||
));
|
||||
const questionLoadFailed = questionSource === "unavailable";
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& !liveQuestionOnMessages
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showQuestionLoadFailed = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& questionLoadFailed,
|
||||
);
|
||||
// The gap between a settled turn and its next question has two visible
|
||||
// states: `preparing` (one live timeline row, timed refetches) and
|
||||
// `unavailable` (copy and a reload button) — never bare copy telling the
|
||||
// reader to wait for the server.
|
||||
const questionGap = rectificationQuestionGapState({
|
||||
liveQuestionVisible: liveQuestionOnMessages,
|
||||
questionMissing: currentQuestion === null,
|
||||
questionLoadFailed: questionSource === "unavailable",
|
||||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
|
||||
busy,
|
||||
readonly,
|
||||
regenerating: regeneratingMessageKey !== null,
|
||||
snapshotLoaded: caseSnapshotLoaded,
|
||||
resumableCase,
|
||||
retryAttempts: questionRetryAttempts,
|
||||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
});
|
||||
const conversationState = rectificationConversationState({
|
||||
messageCount: messages.length,
|
||||
busy,
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
openingStarted: openingRequested,
|
||||
});
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
// Question recovery: the turn already refetched once on completion; while
|
||||
// the gap is `preparing`, refetch on a timer up to the retry limit, then
|
||||
// hand the reader a reload button. Attempts reset once a question arrives
|
||||
// or another turn starts (both in handlers, never in an effect).
|
||||
useVisibilityAwarePoll({
|
||||
enabled: questionGap === "preparing",
|
||||
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
refreshOnVisible: false,
|
||||
onPoll: () => {
|
||||
setQuestionRetryAttempts((current) => current + 1);
|
||||
void refetchQuestion();
|
||||
},
|
||||
});
|
||||
async function refetchQuestion() {
|
||||
await loadCaseSnapshot();
|
||||
}
|
||||
function reloadQuestion() {
|
||||
setQuestionRetryAttempts(0);
|
||||
void refetchQuestion();
|
||||
}
|
||||
|
||||
// A Case with no turns and no automatic first turn (the server never starts
|
||||
// an opening for a resumed Case) needs a way to begin; the server suppresses
|
||||
// a duplicate opening, so this is safe to press twice.
|
||||
function startOpeningManually() {
|
||||
if (readonly || busy || openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
void send("opening", "");
|
||||
}
|
||||
|
||||
function submitChoice(key: ChoiceKey) {
|
||||
if (!choiceCard) return;
|
||||
void submitStructuredChoice(CHOICE_ACTION, key);
|
||||
@@ -1403,6 +1463,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{conversationState === "empty" && (
|
||||
<div className="rectification-empty-state">
|
||||
<p>{RECTIFICATION_EMPTY_COPY}</p>
|
||||
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
@@ -1527,6 +1593,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{questionGap === "preparing" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap">
|
||||
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{savedTime && savedStatus === "confirmed" && (
|
||||
<p className="rectification-saved" role="status">
|
||||
已确认校正时间:{savedTime}
|
||||
@@ -1564,15 +1643,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
|
||||
</div>
|
||||
)}
|
||||
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
|
||||
<p className="rectification-composer-status" role="status">
|
||||
{showQuestionLoadFailed
|
||||
? "题目加载失败,请刷新。"
|
||||
: showMissingQuestion
|
||||
? "当前没有可回答的问题,正在等待服务端更新。"
|
||||
: "当前问题暂时无法显示,请等待服务端更新。"}
|
||||
</p>
|
||||
)}
|
||||
<ChatComposer
|
||||
inputRef={composer}
|
||||
value={draft}
|
||||
|
||||
Reference in New Issue
Block a user