@@ -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 }