From 3a66c39f4852aa4c496002f5ac5dbbc9d7f04f98 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 26 Sep 2026 19:23:52 +0800 Subject: [PATCH] refactor(rectification): split the chat component into hooks, parameter functions and blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API, copy, DB, Skill or scoring change. - rectification-agentic-chat.tsx: 2043 -> 737 lines; component body 1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5, useCallback 12 -> 6. - Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout and live label clock -> two small hooks. - send / submitStructuredChoice / acceptCandidate bodies -> lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions (bodies verbatim, deps destructured to the same names); copy/regenerate, question repair, pure transcript and snapshot helpers and the per-render view derivation -> lib/rectification-chat-*.ts (no React hooks). - Question-gap blocks and the read-only range line -> components/rectification-question-gap-notices.tsx. - Dependency arrays kept exactly as before (lint warnings +3, listed in PROGRESS); no dep added to appease the linter. - Tests: whole-source contracts read tests/rectification-chat-surface.ts (container + split files, like home-surface.ts); slices of moved code now call the extracted functions or render the extracted block, each with a 原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- frontend/DESIGN.md | 2 +- .../components/rectification-agentic-chat.tsx | 1754 +++-------------- .../rectification-question-gap-notices.tsx | 151 ++ .../hooks/use-rectification-board-layout.ts | 53 + .../hooks/use-rectification-case-snapshot.ts | 211 ++ .../hooks/use-rectification-live-activity.ts | 66 + .../src/lib/rectification-chat-accept-run.ts | 183 ++ .../src/lib/rectification-chat-choice-run.ts | 283 +++ .../lib/rectification-chat-message-actions.ts | 88 + .../src/lib/rectification-chat-messages.ts | 163 ++ .../lib/rectification-chat-question-repair.ts | 99 + .../src/lib/rectification-chat-snapshot.ts | 94 + .../src/lib/rectification-chat-turn-run.ts | 510 +++++ frontend/src/lib/rectification-chat-view.ts | 291 +++ .../account-dialog-inert-20260918.test.ts | 4 +- .../tests/agent-voice-copy-contract.test.ts | 10 +- frontend/tests/chat-composer-queue.test.ts | 34 +- .../chat-notice-and-scroll-contract.test.ts | 7 +- frontend/tests/chat-stream-layout.test.ts | 7 +- frontend/tests/composer-ime-contract.test.ts | 7 +- .../tests/composer-isolation-contract.test.ts | 7 +- .../tests/consultation-entrypoint.test.ts | 10 +- .../rectification-activity-receipt.test.ts | 65 +- ...fication-adopt-cards-stay-20260909.test.ts | 45 +- .../rectification-adopt-flow-20260902.test.ts | 7 +- ...tification-adopt-flow-fix-20260903.test.ts | 13 +- .../tests/rectification-agentic-entry.test.ts | 122 +- .../tests/rectification-answer-choice.test.ts | 4 +- .../tests/rectification-chat-run-fixtures.ts | 361 ++++ frontend/tests/rectification-chat-surface.ts | 34 + ...tification-dead-d9-choice-20260916.test.ts | 7 +- ...tion-delivery-ui-simplify-20260908.test.ts | 4 +- ...tion-engine-version-cross-midnight.test.ts | 4 +- ...ification-exhaustion-exit-20260906.test.ts | 24 +- .../rectification-growth-contract.test.ts | 88 + ...ification-occupation-coverage-exit.test.ts | 4 +- ...on-other-collect-fallback-20260908.test.ts | 4 +- ...ication-post-adopt-verify-20260904.test.ts | 39 +- ...ication-probe-replay-loss-20260908.test.ts | 26 +- .../rectification-question-in-message.test.ts | 4 +- .../rectification-question-ownership.test.ts | 4 +- .../rectification-range-bar-20260916.test.ts | 4 +- ...tification-range-delivery-20260907.test.ts | 7 +- ...rectification-settled-render-split.test.ts | 7 +- ...on-skipped-health-deadend-20260909.test.ts | 4 +- .../tests/rectification-spoken-answer.test.ts | 7 +- .../rectification-spoken-collect.test.ts | 27 +- .../rectification-step-state-20260906.test.ts | 4 +- .../rectification-surface-contract.test.ts | 36 +- ...cation-targeted-card-live-20260913.test.ts | 4 +- ...ted-spoken-focus-recovery-20260913.test.ts | 4 +- ...ification-tie-break-entry-20260913.test.ts | 4 +- ...cation-tiebreak-card-loss-20260915.test.ts | 40 +- .../rectification-timeline-20260909.test.ts | 58 +- .../rectification-timeline-adapter.test.ts | 4 +- .../rectification-unwritten-evidence.test.ts | 7 +- ...tion-v10-focus-receipt-consistency.test.ts | 7 +- .../tests/rectification-v9-contracts.test.ts | 4 +- .../rectification-varga-style-copy.test.ts | 4 +- .../rectification-walkthrough-polish.test.ts | 4 +- ...cation-year-focus-overlay-20260916.test.ts | 4 +- 61 files changed, 3402 insertions(+), 1731 deletions(-) create mode 100644 frontend/src/components/rectification-question-gap-notices.tsx create mode 100644 frontend/src/hooks/use-rectification-board-layout.ts create mode 100644 frontend/src/hooks/use-rectification-case-snapshot.ts create mode 100644 frontend/src/hooks/use-rectification-live-activity.ts create mode 100644 frontend/src/lib/rectification-chat-accept-run.ts create mode 100644 frontend/src/lib/rectification-chat-choice-run.ts create mode 100644 frontend/src/lib/rectification-chat-message-actions.ts create mode 100644 frontend/src/lib/rectification-chat-messages.ts create mode 100644 frontend/src/lib/rectification-chat-question-repair.ts create mode 100644 frontend/src/lib/rectification-chat-snapshot.ts create mode 100644 frontend/src/lib/rectification-chat-turn-run.ts create mode 100644 frontend/src/lib/rectification-chat-view.ts create mode 100644 frontend/tests/rectification-chat-run-fixtures.ts create mode 100644 frontend/tests/rectification-chat-surface.ts create mode 100644 frontend/tests/rectification-growth-contract.test.ts diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index e175a2f9..06b43c33 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -798,7 +798,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。 -**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。 +**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-chat-view.ts` 的 `deriveRectificationChatView` 算一次(容器 `rectification-agentic-chat.tsx` 每次渲染调用它一次;2026-09-26 代码拆分只搬位置)、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。 **条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1e6d0ab1..d210ec7e 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1,117 +1,37 @@ "use client"; -import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useQueuedMessage } from "@/hooks/use-queued-message"; import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft"; import { createPortal } from "react-dom"; -import { parseAgentReply } from "@/lib/agent-reply"; -import { nextActivityView, type AgentActivityView, type ChatMessage } 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_ANALYZING_LIVE_LABEL, - RECTIFICATION_TOOL_DONE_LABELS, - RECTIFICATION_TOOL_PROGRESS_LABELS, - RECTIFICATION_TURN_PROGRESS_LABELS, - activityTraceFromReceipt, - rectificationCompletedTrail, - rectificationLiveProgressLabel, - rectificationToolActivityPhase, - rectificationTurnProgressPhase, -} from "@/lib/rectification-activity-labels"; -import { isRectificationTurnProgressStage } from "@/lib/rectification-agentic/v9/turn-progress"; -import { withLiveStepLabel } from "@/lib/rectification-timeline-adapter"; -import { - createRectificationActivityReceiptState, - receiptFromRectificationActivityState, - reduceRectificationActivityReceipt, - type CompletedActivityReceiptView, -} from "@/lib/rectification-activity-receipt"; -import { - canShowRectificationReadonlyRange, - canShowRectificationSelectionCards, - natalRecastMeaning, - parseRectificationCandidateResult, - workingRectificationHouseTable, - type RectificationCandidateResult, -} from "@/lib/rectification-candidate-result"; +import type { ChatMessage } from "@/lib/chat-message-view"; +import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result"; import { RectificationRangeDelivery } from "@/components/rectification-range-delivery"; import { - diffRectificationBoard, - RECTIFICATION_BOARD_SPLIT_MIN_PX, - workingRectificationTime, -} from "@/lib/rectification-board-model"; -import { - rectificationAdoptingLabel, RECTIFICATION_EMPTY_ACTION_LABEL, RECTIFICATION_EMPTY_COPY, - RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, - RECTIFICATION_QUESTION_PREPARING_LABEL, - RECTIFICATION_QUESTION_RELOAD_LABEL, - RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, - RECTIFICATION_QUESTION_REPAIR_LIMIT, RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, - RECTIFICATION_QUESTION_RETRY_LIMIT, - RECTIFICATION_QUESTION_UNAVAILABLE_COPY, - RECTIFICATION_SNAPSHOT_RETRY_LABEL, - RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY, - RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, - RECTIFICATION_DELIVERED_COPY, - isAbortError, - rectificationConversationState, - rectificationInitialLiveLabel, recompareHistoricalResult, - rectificationQuestionGapState, - rectificationReadonlyRangeCopy, contrastProbesFromReceipt, - searchWindowFromSnapshot, - caseStageFromSnapshot, - interviewCollectWaiting, - interviewChoiceCardUnavailable, - persistedQuestionSurface, - interviewStopReasonFromSnapshot, - interviewSessionOutcomeFromSnapshot, - birthTimeSourceFromSnapshot, type RectificationCaseSnapshotPayload, - type RectificationSnapshotFailure, } from "@/lib/rectification-surface-state"; import { RectificationTimeline } from "@/components/rectification-timeline"; -import { - buildRectificationTimeline, - type RectificationTimelineStage, -} from "@/lib/rectification-timeline-scale"; -import { - isPublicRectificationActivity, - isPublicRectificationMethod, - isPublicRectificationTool, -} from "@/lib/rectification-agentic/v9/public-receipt"; -import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; import { CHOICE_ACTION, SKIP_PROBE_ACTION, STOP_ACTION, - isStructuredChoiceUserText, - shouldContinueAfterStructuredChoice, stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; -import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy"; -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 { ChoiceKey } 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 { useRectificationCaseSnapshot } from "@/hooks/use-rectification-case-snapshot"; +import { useRectificationBoardLayout } from "@/hooks/use-rectification-board-layout"; +import { useRectificationLiveActivity } from "@/hooks/use-rectification-live-activity"; import { ChatComposer } from "./chat-composer"; -import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; import { JumpToLatestButton } from "./jump-to-latest-button"; import { toggleChatMessageFeedback } from "./chat-message-actions"; import { @@ -121,107 +41,36 @@ import { } from "./rectification-message-entry"; import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; -import { RectificationChoiceCard } from "./rectification-choice-card"; +import { + RectificationQuestionGapNotices, + RectificationReadonlyRange, +} from "./rectification-question-gap-notices"; import { applyLiveCandidateOffer, - copyTextForMessage, - interviewQuestionBlocksAdoptOffer, - nextSelectionCardLock, - parseTurnQuestion, - questionIsAnswered, - resolveSelectionCardMessageKey, - turnQuestionFromCurrent, type SelectionCardLock, } from "@/lib/rectification-agentic/v9/turn-question"; import { Button } from "@/components/ui/button"; +import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages"; +import { runRectificationChatTurn } from "@/lib/rectification-chat-turn-run"; import { - appendUnseenAssistantTurns, - applySnapshotTurnsToMessages, - mergeTurnQuestions, - placePersistedQuestion, -} from "@/lib/rectification-snapshot-messages"; - -type PersistedTurn = Readonly<{ - id: string; - role: "user" | "assistant"; - text: string | null; - status: string; - question?: unknown; - offer_result_id?: string | null; - receipt?: Readonly<{ - status: string; - phases: readonly string[]; - tool_activities?: readonly Readonly<{ - tool: string; - status: string; - methods?: readonly string[]; - started_at?: string | null; - elapsed_ms?: number | null; - detail?: Readonly> | null; - }>[]; - tools: readonly string[]; - methods?: readonly string[]; - skill_name?: string; - skill_version?: string; - }> | null; -}>; + runRectificationStructuredChoice, + type RectificationStructuredChoiceAction, + type RectificationStructuredChoiceOverride, +} from "@/lib/rectification-chat-choice-run"; +import { runRectificationCandidateAccept } from "@/lib/rectification-chat-accept-run"; +import { + copyRectificationMessage, + regenerateRectificationMessage, +} from "@/lib/rectification-chat-message-actions"; +import { + refetchRectificationQuestion, + repairRectificationQuestion, +} from "@/lib/rectification-chat-question-repair"; +import { deriveRectificationChatView } from "@/lib/rectification-chat-view"; /** 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; - focus_id: string | null; - question_id: 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; focus_id?: unknown; question_id?: unknown }; - if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") { - return null; - } - return { - kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice", - prompt: typeof question.prompt === "string" && question.prompt.trim() - ? question.prompt.trim() - : null, - focus_id: typeof question.focus_id === "string" ? question.focus_id : null, - question_id: typeof question.question_id === "string" ? question.question_id : null, - }; -} - -function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null { - if (value === "unavailable") return "unavailable"; - if (value === "focus") return "focus"; - return null; -} - -function RectificationReadonlyRange({ - range, - sessionOutcome, - discriminatingEventProbes, - contrastProbes, -}: Readonly<{ - range: readonly [string, string]; - sessionOutcome: string | null; - discriminatingEventProbes: unknown; - contrastProbes: unknown; -}>) { - return ( -

- {rectificationReadonlyRangeCopy({ - range, - sessionOutcome, - discriminatingEventProbes, - contrastProbes, - })} -

- ); -} - type RectificationAgenticChatProps = Readonly<{ caseId: string; sessionId: string; @@ -249,76 +98,6 @@ type RectificationAgenticChatProps = Readonly<{ headerSlot: HTMLElement | null; }>; -function markQuestionAnswered( - current: RenderMessage[], - focusId: string, - selected: ChoiceKey | "stop" | "skip_probe" | "typed", -): RenderMessage[] { - return current.map((message) => { - const question = message.question; - if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message; - return { - ...message, - question: { - ...question, - status: "resolved", - answer_option: selected === "typed" ? null : selected, - }, - }; - }); -} - -/** - * BUG-1046: undo `markQuestionAnswered` when the answer never reached the - * server, so the question is active again with no option picked. Only the mark - * this action made is undone; a server-resolved question is left alone. - */ -function withdrawAnswerMark( - current: RenderMessage[], - focusId: string, - selected: ChoiceKey | "stop" | "skip_probe" | "typed", -): RenderMessage[] { - const marked = selected === "typed" ? null : selected; - return current.map((message) => { - const question = message.question; - if (!question || question.focus_id !== focusId) return message; - if (question.status !== "resolved" || question.answer_option !== marked) return message; - return { ...message, question: { ...question, status: "active", answer_option: null } }; - }); -} - -function isSettledAssistantMessage(message: RenderMessage): boolean { - return message.role === "assistant" && message.state === "settled" && !message.failed; -} - -function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] { - return Array.isArray(payload?.turns) ? payload.turns : []; -} - -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", @@ -326,96 +105,6 @@ export function toggleRectificationFeedback( 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, - question: parseTurnQuestion(turn.question) ?? undefined, - candidateOffer: typeof turn.offer_result_id === "string" - ? { resultId: turn.offer_result_id } - : undefined, - }]; - } - if (isIncompleteRunBanner(turn.text ?? "")) return []; - if (isStructuredChoiceUserText(turn.text)) return []; - return [{ - role: "user", - text: turn.text ?? "", - renderKey: key, - state: "settled", - }]; - }); -} - -type CaseSnapshotState = Readonly<{ - candidate: CandidateResult; - question: CurrentQuestionModel | null; - questionSource: "focus" | "unavailable" | null; - choice: ChoiceCardModel | null; - caseStatus: RectificationCaseStatus | null; - savedTime: string | null; - savedStatus: "accepted" | "confirmed" | null; - nextUserActionId: string | null; - searchWindow: readonly [string, string] | null; - stage: RectificationTimelineStage | null; - interviewStopReason: string | null; - interviewSessionOutcome: string | null; - birthTimeSource: ReturnType; -}>; - -function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null { - const id = payload?.next_user_action?.id; - return typeof id === "string" && id.trim() ? id.trim() : null; -} - -/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */ -function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null { - if (!payload) return null; - const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; - const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; - return { - candidate: parseRectificationCandidateResult(payload.latest_result), - question: currentQuestionFromSnapshot(payload.current_question), - questionSource: questionSourceFromSnapshot(payload.question_source), - choice: parseRectificationChoiceCard(payload.choice_card), - caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, - savedTime: confirmedTime ?? acceptedTime, - savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, - nextUserActionId: nextUserActionIdFromSnapshot(payload), - searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range), - stage: caseStageFromSnapshot(payload.case?.stage), - interviewStopReason: interviewStopReasonFromSnapshot(payload), - interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload), - birthTimeSource: birthTimeSourceFromSnapshot(payload), - }; -} - export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, @@ -441,8 +130,41 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { headerSlot, } = props; - const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null); - const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true); + // Case snapshot sync (candidate, current question, choice card, saved time, + // timeline window / stage, question recovery counters) lives in its own hook. + const { + resultNotice, + canRecompare, + savedTime, + setSavedTime, + savedStatus, + setSavedStatus, + candidateResult, + setCandidateResult, + choiceCard, + currentQuestion, + questionSource, + caseStatus, + searchWindow, + caseStage, + nextUserActionId, + setNextUserActionId, + interviewStopReason, + interviewSessionOutcome, + birthTimeSource, + caseSnapshotLoaded, + questionRetryAttempts, + setQuestionRetryAttempts, + questionRepairAttempts, + setQuestionRepairAttempts, + questionRepairing, + setQuestionRepairing, + snapshotFailure, + snapshotAbort, + currentQuestionRef, + applyCaseSnapshot, + loadCaseSnapshot, + } = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot }); const readonly = historyReadonly || Boolean(resultNotice); const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare); const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); @@ -451,24 +173,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const lastRunOutcome = useRef<"succeeded" | "stopped" | "failed" | "readonly" | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); - const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); - const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); - const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); - const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); - const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); - const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); - const [searchWindow, setSearchWindow] = useState(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null); - const [caseStage, setCaseStage] = useState(() => caseSnapshotState(initialSnapshot)?.stage ?? null); - const [nextUserActionId, setNextUserActionId] = useState(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null); - const [interviewStopReason, setInterviewStopReason] = useState(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null); - const [interviewSessionOutcome, setInterviewSessionOutcome] = useState(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null); - const [birthTimeSource, setBirthTimeSource] = useState(() => caseSnapshotState(initialSnapshot)?.birthTimeSource ?? birthTimeSourceFromSnapshot(initialSnapshot)); - const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); - const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); - const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0); - const [questionRepairing, setQuestionRepairing] = useState(false); - const [snapshotFailure, setSnapshotFailure] = useState(null); const [openingRequested, setOpeningRequested] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); @@ -487,48 +191,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); - const previousBoardResult = useRef(null); const runAbort = useRef(null); - const snapshotAbort = useRef(null); const choiceActionIds = useRef(new Map()); - const currentQuestionRef = useRef(currentQuestion); - const runStartedAtRef = useRef(0); - const stepStartedAtRef = useRef(0); - const liveToolRef = useRef(null); - const liveBaseLabelRef = useRef("正在处理…"); const [selectionOfferLock, setSelectionOfferLock] = useState(null); - const [compactBoard, setCompactBoard] = useState(false); - const [boardOpen, setBoardOpen] = useState(false); - const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); - const boardId = useId(); - const boardTitleId = useId(); const composerRemainingId = useId(); // Same pin-the-turn-head hook as the consultation surface (BUG-930). const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId); - - useLayoutEffect(() => { - currentQuestionRef.current = currentQuestion; - }, [currentQuestion]); - - 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 { + compactBoard, + boardOpen, + boardDiff, + boardId, + boardTitleId, + closeBoard, + toggleBoard, + } = useRectificationBoardLayout(candidateResult); + const { beginLiveRun, rememberLiveActivity } = useRectificationLiveActivity({ busy, setMessages }); const stopRun = useCallback(() => { runAbort.current?.abort(); @@ -542,46 +220,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onPendingChange?.(value); }, [onPendingChange]); - const beginLiveRun = useCallback((baseLabel: string) => { - const now = Date.now(); - runStartedAtRef.current = now; - stepStartedAtRef.current = now; - liveToolRef.current = null; - liveBaseLabelRef.current = baseLabel; - }, []); - - const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => { - if (liveBaseLabelRef.current !== baseLabel) { - liveBaseLabelRef.current = baseLabel; - stepStartedAtRef.current = Date.now(); - } - liveToolRef.current = tool; - return rectificationLiveProgressLabel({ - tool, - baseLabel, - stepStartedAt: stepStartedAtRef.current, - runStartedAt: runStartedAtRef.current, - }); - }, []); - - useEffect(() => { - if (!busy) return; - const id = window.setInterval(() => { - const label = rectificationLiveProgressLabel({ - tool: liveToolRef.current, - baseLabel: liveBaseLabelRef.current, - stepStartedAt: stepStartedAtRef.current, - runStartedAt: runStartedAtRef.current, - }); - setMessages((current) => current.map((message) => { - if (message.state !== "thinking" && message.state !== "streaming") return message; - if (!message.activity || message.activity.label === label) return message; - return { ...message, activity: { ...message.activity, label } }; - })); - }, 4000); - return () => window.clearInterval(id); - }, [busy]); - useEffect(() => { if (!candidateResult?.resultId) return; const canOffer = canShowRectificationSelectionCards(candidateResult); @@ -597,109 +235,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }); }, [candidateResult]); - // Candidate snapshot comes from the persisted Candidate Snapshot API, never - // from parsing agent text or hidden sentinels. - const applyCaseSnapshot = useCallback((payload: RectificationCaseSnapshotPayload | { - latest_result?: unknown; - current_question?: unknown; - choice_card?: unknown; - step_state?: unknown; - turns?: unknown; - question_source?: unknown; - next_user_action?: { id?: unknown }; - interview?: { stop_reason?: unknown; session_outcome?: unknown }; - case?: { - status?: unknown; - accepted_time?: unknown; - confirmed_time?: unknown; - candidate_range?: unknown; - stage?: unknown; - }; - } | null) => { - if (!payload) return; - const snapshot = payload as RectificationCaseSnapshotPayload; - setResultNotice(snapshot.case?.result_notice ?? null); - setCanRecompare(snapshot.case?.result_identity?.can_recompare === true); - 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; - const nextActionId = typeof payload.next_user_action?.id === "string" - ? payload.next_user_action.id.trim() - : ""; - // Only overwrite when the payload actually carries them: a snapshot without - // a window should leave the timeline showing the last known one rather than - // dropping to the skeleton mid-session. - const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range); - if (nextWindow) setSearchWindow(nextWindow); - const nextStage = caseStageFromSnapshot(payload.case?.stage); - if (nextStage) setCaseStage(nextStage); - setCandidateResult(nextCandidate); - setCurrentQuestion(nextQuestion); - setQuestionSource(questionSourceFromSnapshot(payload.question_source)); - setChoiceCard(nextChoice); - setCaseStatus(nextCaseStatus); - setNextUserActionId(nextActionId || null); - setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot)); - setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot)); - setBirthTimeSource(birthTimeSourceFromSnapshot(snapshot)); - setCaseSnapshotLoaded(true); - if (nextQuestion || nextChoice || acceptedTime || confirmedTime) { - setQuestionRepairAttempts(0); - } - if (confirmedTime) { - setSavedTime(confirmedTime); - setSavedStatus("confirmed"); - } else if (acceptedTime) { - setSavedTime(acceptedTime); - setSavedStatus("accepted"); - } - }, []); - - const loadCaseSnapshot = useCallback(async ( - mergeMessages?: (turns: readonly unknown[]) => void, - ): Promise<{ - question: CurrentQuestionModel | null; - turns: readonly unknown[]; - } | null | undefined> => { - snapshotAbort.current?.abort(); - const controller = new AbortController(); - snapshotAbort.current = controller; - try { - const response = await fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, - { cache: "no-store", signal: controller.signal }, - ); - if (controller.signal.aborted) return undefined; - if (!response.ok) { - setSnapshotFailure({ status: response.status }); - return undefined; - } - const payload = await response.json().catch(() => null); - if (controller.signal.aborted) return undefined; - const turns = snapshotTurns(payload); - startTransition(() => { - setSnapshotFailure(null); - applyCaseSnapshot(payload); - mergeMessages?.(turns); - }); - return { - question: currentQuestionFromSnapshot(payload?.current_question), - turns, - }; - } catch (error) { - if (isAbortError(error) || controller.signal.aborted) return undefined; - setSnapshotFailure({ status: "network" }); - return undefined; - } finally { - if (snapshotAbort.current === controller) snapshotAbort.current = null; - } - }, [applyCaseSnapshot, caseId, sessionId]); - // Turns that arrive after mount (the parent refreshes the Case after each // completed turn) only ever fill an empty transcript; a live or finished // conversation is never overwritten or remounted. Adjusted during render @@ -718,398 +253,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, []); /** - * Run one agent turn. A `continuation` reuses the live row an earlier step - * (a tapped choice, an adopted candidate) already put at the end of the - * transcript, so the reader sees one uninterrupted working row from the tap - * to the next question instead of a gap and a re-appended row. The caller - * of a continuation already holds `busy`. + * Run one agent turn (runRectificationChatTurn). A `continuation` reuses the + * live row an earlier step already put at the end of the transcript; the + * caller of a continuation already holds `busy`. */ const send = useCallback(async ( action: "opening" | "message" | "read_only", messageText: string, continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>, - ) => { - const trimmed = action === "message" ? messageText.trim() : ""; - if ((action === "message" && !trimmed) || conversationReadonly) return; - if (!continuation && busy) return; - setError(""); - setPending(true); - const initialLabel = rectificationInitialLiveLabel(action, continuation?.label); - beginLiveRun(initialLabel); - - keyCounter.current += 1; - const requestId = globalThis.crypto.randomUUID(); - const turnKey = keyCounter.current; - const userRenderKey = `v9-user-${turnKey}`; - const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`; - - const pendingFocusId = currentQuestionRef.current?.focus_id; - const liveRow: RenderMessage = { - role: "assistant", - text: "", - renderKey: assistantRenderKey, - state: "thinking", - activityTrace: emptyActivityTrace(), - activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() }, - }; - setMessages((current) => (continuation - ? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message)) - : [ - ...(action === "message" && pendingFocusId - ? markQuestionAnswered(current, pendingFocusId, "typed") - : current), - ...(action === "message" - ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] - : []), - liveRow, - ])); - if (!continuation) conversationAnchor.pinLatestTurn(); - // The typed answer never reached the server: the question it marked is - // still the pending one (BUG-1046, same as a failed tap). - const withdrawTypedMark = (current: RenderMessage[]) => ( - !continuation && action === "message" && pendingFocusId - ? withdrawAnswerMark(current, pendingFocusId, "typed") - : current - ); - let raw = ""; - let runOutcome: "succeeded" | "stopped" | "failed" = "failed"; - let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); - let activityReceiptState = createRectificationActivityReceiptState(); - let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); - let completedTurnId: string | undefined; - let currentActivity: AgentActivityView | undefined = { - phase: "evidence-validation", - label: initialLabel, - 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({ - 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, - }), - }); - // A turn the server turned down: the old non-2xx response, or the same - // facts as a `turn.rejected` event once the stream is already open - // (BUG-1047). Either way the live row goes and the typed mark returns. - const rejectTurn = ( - response: { status: number }, - payload: { code?: unknown; message?: unknown; error?: unknown } | null, - ) => { - const message = (typeof payload?.message === "string" && payload.message) - || (typeof payload?.error === "string" && payload.error) - || `请求失败(${response.status})`; - setMessages((current) => withdrawTypedMark( - current.filter((message) => message.renderKey !== assistantRenderKey), - )); - if (payload?.code === "profile_incomplete") { - onProfileIncomplete?.(); - return; - } - if (response.status === 402) { - // Keep the conversation on this page; the settings pane covers billing. - setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); - onOpenBilling?.({ source: "rectification" }); - return; - } - if (response.status === 401) setError("请先登录。"); - else setError(message); - }; - if (!response.ok) { - const payload = await response.json().catch(() => null); - rejectTurn(response, payload); - return; - } - if (!response.body) { - setMessages((current) => withdrawTypedMark( - 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 = ""; - let rejected: { status: number; payload: { code?: unknown; message?: unknown } } | null = null; - // Stage line of a typed answer (BUG-1047 D3); null until the first - // `turn.progress`. It replaces the generic tool / "正在分析…" labels. - let stageLabel: string | null = null; - while (!rejected) { - 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; - stage?: unknown; - httpStatus?: unknown; - }; - try { - event = JSON.parse(line) as typeof event; - } catch { - continue; - } - if (typeof event.type !== "string") continue; - if (event.type === "turn.rejected" && typeof event.httpStatus === "number") { - rejected = { status: event.httpStatus, payload: { code: event.code, message: event.message } }; - break; - } - if (event.type === "turn.progress" && isRectificationTurnProgressStage(event.stage)) { - stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS[event.stage]; - activityTrace = withLiveStepLabel(activityTrace, stageLabel); - currentActivity = nextActivityView(currentActivity, { - phase: rectificationTurnProgressPhase(event.stage), - label: rememberLiveActivity(stageLabel, null), - }); - frames.touch(); - } else 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: rememberLiveActivity("正在组织回答…"), - }); - frames.setAnswer(raw); - } else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) { - const activity = event.activity; - currentActivity = nextActivityView(currentActivity, { - phase: "evidence-validation", - label: rememberLiveActivity(stageLabel ?? RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]), - }); - frames.touch(); - } else if (event.type === "attempt.reset") { - raw = ""; - activityTrace = emptyActivityTrace(); - activityReceiptState = createRectificationActivityReceiptState(); - completedReceipt = receiptFromRectificationActivityState(activityReceiptState); - completedTurnId = undefined; - if (stageLabel) stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS.received; - currentActivity = nextActivityView(undefined, { - phase: "evidence-validation", - label: rememberLiveActivity(stageLabel ?? "正在处理…", null), - }); - 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, - stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool], - ); - currentActivity = nextActivityView(currentActivity, { - phase: rectificationToolActivityPhase(tool), - label: rememberLiveActivity(stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool], 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: "evidence-validation", - label: stageLabel - ? rememberLiveActivity(stageLabel, null) - : rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null), - completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), - }); - frames.touch(); - } - } - } - frames.settle(); - if (rejected) { - await reader.cancel().catch(() => undefined); - rejectTurn({ status: rejected.status }, rejected.payload); - return; - } - - 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) { - runOutcome = "succeeded"; - await loadCaseSnapshot((turns) => { - if (turns.length) { - // D4 (BUG-1046): a turn the server wrote alongside this one (the - // one carrying the next question) joins the transcript too. - setMessages((current) => applySnapshotTurnsToMessages( - current, - turns, - (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), - )); - } - }); - onMessagesChange?.([ - ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), - { role: "assistant", text: parsed.text }, - ]); - onCompleted?.(); - } else { - await loadCaseSnapshot((turns) => { - if (turns.length) { - setMessages((current) => mergeTurnQuestions(current, turns)); - } - }); - } - } catch (caught) { - frames.settle(); - const aborted = isAbortError(caught); - if (aborted) { - runOutcome = "stopped"; - setMessages((current) => current.map((message) => { - if (message.renderKey !== assistantRenderKey) return message; - return { - ...message, - text: raw, - activityTrace: completeActivityTrace(activityTrace), - state: "settled" as const, - completedReceipt, - failed: false, - stopped: true, - turnId: completedTurnId, - activity: undefined, - }; - })); - return; - } - setError("生时校正暂时不可用,请稍后再试。"); - setMessages((current) => withdrawTypedMark(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; - lastRunOutcome.current = readonly ? "readonly" : runOutcome; - setPending(false); - } - }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); + ) => runRectificationChatTurn({ + caseId, + sessionId, + selectedModelId, + busy, + readonly, + conversationReadonly, + keyCounter, + currentQuestionRef, + runAbort, + lastRunOutcome, + conversationAnchor, + setError, + setMessages, + setPending, + beginLiveRun, + rememberLiveActivity, + loadCaseSnapshot, + onCompleted, + onMessagesChange, + onOpenBilling, + onProfileIncomplete, + }, action, messageText, continuation), [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); useEffect(() => { if (busy) return; @@ -1135,186 +309,33 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, []); const submitStructuredChoice = useCallback(async ( - action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION, + action: RectificationStructuredChoiceAction, optionId: ChoiceKey | "stop" | "skip_probe", - override?: Readonly<{ - focusId?: string; - questionId?: string | null; - probeId?: string | null; - caseRevision?: number | null; - }>, - ) => { - const focusId = override?.focusId ?? choiceCard?.focus_id ?? ( - action === STOP_ACTION && currentQuestion?.kind === "collect_spoken" - ? currentQuestion.focus_id - : null - ); - if (busy || readonly) return; - if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return; - if (focusId && !isPersistedFocusId(focusId)) { - setError("当前选择题已失效,请等待下一问。"); - return; - } - const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null; - const probeId = override?.probeId ?? choiceCard?.probe_id; - const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0; - const actionId = actionIdForChoice(focusId ?? caseId, optionId); - setError(""); - keyCounter.current += 1; - const turnKey = keyCounter.current; - const assistantRenderKey = `v9-choice-assistant-${turnKey}`; - const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; - beginLiveRun(recordingLabel); - setMessages((current) => [ - ...markQuestionAnswered(current, focusId ?? "", optionId), - { - role: "assistant", - text: "", - renderKey: assistantRenderKey, - state: "thinking", - activityTrace: emptyActivityTrace(), - activity: { - phase: "evidence-validation", - label: recordingLabel, - startedAt: Date.now(), - }, - }, - ]); - conversationAnchor.pinLatestTurn(); - setPending(true); - // BUG-1046 (D2): a tap that did not land takes back its local answered - // mark and placeholder; the card key changes (answer_option → nonce), so - // the card remounts clean and one tappable card is left. - const rollBackFailedChoice = () => { - setMessages((current) => withdrawAnswerMark( - current.filter((message) => message.renderKey !== assistantRenderKey), - focusId ?? "", - optionId, - )); - setChoiceNonce((current) => current + 1); - }; - // The server may have moved on (stale_question, a dropped response after - // it applied): re-read the Case so the card reflects what it holds. - const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => { - setMessages((current) => applySnapshotTurnsToMessages( - current, - turns, - (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), - )); - }); - 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: actionId, - action, - actionId, - ...(focusId ? { focusId } : {}), - ...(questionId ? { questionId } : {}), - probeId: probeId ?? null, - optionId: optionId === "stop" ? undefined : optionId, - expectedRevision, - origin: "choice_click", - clientActionId: actionId, - }), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) { - rollBackFailedChoice(); - if (payload?.code === "profile_incomplete") { - onProfileIncomplete?.(); - return; - } - setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); - await reloadAfterFailedChoice(); - return; - } - const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); - const payloadNextActionId = typeof payload?.next_user_action?.id === "string" - ? payload.next_user_action.id.trim() - : ""; - if (payloadNextActionId) setNextUserActionId(payloadNextActionId); - onCompleted?.(); - await loadCaseSnapshot((turns) => { - if (willContinue) { - // D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn - // the server wrote while applying the tap joins the transcript, - // above the row the follow-up run continues on. - setMessages((current) => mergeTurnQuestions( - appendUnseenAssistantTurns( - current, - messagesFromTurns(turns as readonly PersistedTurn[]), - { beforeRenderKey: assistantRenderKey }, - ), - turns, - )); - return; - } - const narration = typeof payload?.narration === "string" && payload.narration.trim() - ? payload.narration.trim() - : "已记录你的选择。"; - setMessages((current) => { - const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); - const withHistory = appendUnseenAssistantTurns( - mergeTurnQuestions(withoutPlaceholder, turns), - messagesFromTurns(turns as readonly PersistedTurn[]), - ); - if (withHistory.length > withoutPlaceholder.length) return withHistory; - return [ - ...withHistory, - { - role: "assistant" as const, - text: narration, - renderKey: assistantRenderKey, - state: "settled" as const, - activity: undefined, - }, - ]; - }); - }); - if (willContinue) { - // The follow-up turn continues on the row already in place: no removed - // row, no effect hop, and `busy` never drops in between (so the next - // card cannot flash before the turn hides it). - await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); - } else { - const narration = typeof payload?.narration === "string" && payload.narration.trim() - ? payload.narration.trim() - : "已记录你的选择。"; - onMessagesChange?.([ - { role: "assistant", text: narration }, - ]); - } - } catch (caught) { - if (isAbortError(caught)) { - lastRunOutcome.current = "stopped"; - setMessages((current) => current.map((message) => ( - message.renderKey !== assistantRenderKey ? message : { - ...message, - state: "settled" as const, - failed: false, - stopped: true, - activity: undefined, - } - ))); - return; - } - lastRunOutcome.current = "failed"; - rollBackFailedChoice(); - setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); - await reloadAfterFailedChoice(); - } finally { - if (runAbort.current === abortController) runAbort.current = null; - if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; - setPending(false); - } - }, [ + override?: RectificationStructuredChoiceOverride, + ) => runRectificationStructuredChoice({ + caseId, + sessionId, + busy, + readonly, + choiceCard, + currentQuestion, + keyCounter, + runAbort, + lastRunOutcome, + conversationAnchor, + actionIdForChoice, + setError, + setMessages, + setChoiceNonce, + setNextUserActionId, + setPending, + beginLiveRun, + loadCaseSnapshot, + send, + onCompleted, + onMessagesChange, + onProfileIncomplete, + }, action, optionId, override), [ beginLiveRun, caseId, choiceCard, @@ -1340,152 +361,46 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 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); - // Adoption is a server round trip followed by an agent turn; the reader sees - // one live row for the whole of it instead of a greyed button. - const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; - const adoptingLabel = rectificationAdoptingLabel(candidateTime); - keyCounter.current += 1; - const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; - beginLiveRun(adoptingLabel); - setMessages((current) => [ - ...current, - { - role: "assistant", - text: "", - renderKey: assistantRenderKey, - state: "thinking", - activityTrace: emptyActivityTrace(), - activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, - }, - ]); - conversationAnchor.pinLatestTurn(); - const abortController = new AbortController(); - runAbort.current = abortController; - try { - const response = await fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, - { - method: "POST", - headers: { "content-type": "application/json" }, - signal: abortController.signal, - body: JSON.stringify({ - sessionId, - resultId: candidateResult.resultId, - candidateId, - requestId: globalThis.crypto.randomUUID(), - mode: "range", - }), - }, - ); - 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(); - await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); - } catch (caught) { - if (isAbortError(caught)) { - lastRunOutcome.current = "stopped"; - setMessages((current) => current.map((message) => ( - message.renderKey !== assistantRenderKey ? message : { - ...message, - state: "settled" as const, - failed: false, - stopped: true, - activity: undefined, - } - ))); - return; - } - lastRunOutcome.current = "failed"; - setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); - setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); - } finally { - if (runAbort.current === abortController) runAbort.current = null; - if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; - setAcceptingCandidateId(null); - setPending(false); - } - }, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); + const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({ + caseId, + sessionId, + busy, + readonly, + candidateResult, + acceptingCandidateId, + keyCounter, + runAbort, + lastRunOutcome, + conversationAnchor, + setError, + setMessages, + setAcceptingCandidateId, + setCandidateResult, + setSavedTime, + setSavedStatus, + setPending, + beginLiveRun, + loadCaseSnapshot, + send, + onCompleted, + onSaved, + }, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); async function copyMessage(message: RenderMessage) { - try { - await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question)); - setCopiedMessageKey(message.renderKey); - window.setTimeout(() => setCopiedMessageKey((current) => ( - current === message.renderKey ? null : current - )), 1_500); - } catch { - // Clipboard permission failures must not interrupt the conversation. - } + await copyRectificationMessage({ setCopiedMessageKey }, message); } 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 || "暂时无法重新生成回答"); - } - const nextText = payload.assistantMessage; - setMessages((current) => current.map((item) => item.renderKey === message.renderKey - ? { ...item, text: nextText, 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); - } + await regenerateRectificationMessage({ + caseId, + sessionId, + busy, + readonly, + regeneratingMessageKey, + setError, + setMessages, + setRegeneratingMessageKey, + }, message); } async function submit(event: React.FormEvent) { @@ -1500,189 +415,51 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { await send("message", text); } - 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 latestLiveQuestion = [...messages].reverse().find((message) => ( - message.role === "assistant" - && message.question - && !questionIsAnswered(message.question) - ))?.question ?? null; - const showLiveChoiceCard = Boolean( - latestLiveQuestion - && latestLiveQuestion.options?.length === 4 - && currentQuestion?.focus_id === latestLiveQuestion.focus_id - && choiceCard - && choiceCard.focus_id === latestLiveQuestion.focus_id - && (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify") - && !busy - && !readonly - && regeneratingMessageKey === null, - ); - const canOfferCards = canShowRectificationSelectionCards(candidateResult); - // Cheap arithmetic over at most a dozen candidate minutes; recomputed with - // the render rather than memoised, matching the rest of this component. - const timelineView = buildRectificationTimeline({ + const { + latestRegeneratableKey, + latestSettledAssistant, + showLiveChoiceCard, + timelineView, + nextOfferLock, + selectionCardMessageKey, + liveInterviewBlockingAdopt, + showSelectionCards, + showReadonlyRange, + collectSpokenPrompt, + liveQuestionOnMessages, + questionGap, + placedByKey, + showStandaloneQuestion, + verifiedIdleCopy, + conversationState, + } = deriveRectificationChatView({ + messages, + busy, + readonly, + regeneratingMessageKey, + shouldStartOpening, + openingRequested, + acceptingCandidateId, + selectionOfferLock, + candidateResult, + currentQuestion, + questionSource, + choiceCard, + caseStatus, + caseSnapshotLoaded, searchWindow, - credibleRange: candidateResult?.credibleRange ?? null, - candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [], - inferenceMarks: candidateResult?.inferenceMarks ?? [], - stage: caseStage, - // The same call the board's title-row clock makes, on the same result, so - // the bar and `.rectification-board__clock` are one number by construction - // rather than by two code paths that happen to agree today. - workingTime: workingRectificationTime(candidateResult), - workingAdopted: Boolean(candidateResult?.selectedTime), - answeredProbeCount: candidateResult?.answeredProbeCount ?? null, - datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null, + caseStage, + nextUserActionId, + interviewStopReason, + interviewSessionOutcome, + questionRetryAttempts, + savedTime, }); - const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; - const liveSelectionCardKey = persistedOfferKey - ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); // Same render-time store update as seededTurns: the lock must be current // before this paint (BUG-620). A ref write here trips react-hooks/refs. - const nextOfferLock = nextSelectionCardLock(selectionOfferLock, { - resultId: candidateResult?.resultId, - key: liveSelectionCardKey, - }); if (nextOfferLock !== selectionOfferLock) { setSelectionOfferLock(nextOfferLock); } - const selectionCardMessageKey = resolveSelectionCardMessageKey({ - persistedOfferKey, - fallbackKey: latestSettledAssistant?.renderKey, - locked: nextOfferLock, - resultId: candidateResult?.resultId, - selectedTime: candidateResult?.selectedTime, - canOffer: canOfferCards, - }); - // Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card - // for `busy` is what made the three columns vanish on 「更像这个」. - const keepSelectionCardsWhileBusy = Boolean( - acceptingCandidateId || candidateResult?.selectedTime, - ); - const liveInterviewBlockingAdopt = Boolean( - candidateResult - && !candidateResult.selectedTime - && messages.some((message) => ( - interviewQuestionBlocksAdoptOffer(message.question, false) - )), - ); - const showSelectionCards = Boolean( - candidateResult - && caseSnapshotLoaded - && (!busy || keepSelectionCardsWhileBusy) - && selectionCardMessageKey - && (canOfferCards || Boolean(candidateResult.selectedTime)) - ); - const showReadonlyRange = Boolean( - canShowRectificationReadonlyRange(candidateResult) - && latestSettledAssistant - && !showSelectionCards, - ); - const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" - ? currentQuestion.prompt - : null; - const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); - const deadChoice = interviewChoiceCardUnavailable({ - questionKind: currentQuestion?.kind, - hasChoiceCard: Boolean(choiceCard), - questionId: currentQuestion?.question_id, - }); - const liveQuestionOnMessages = Boolean( - latestSettledAssistant - && latestSettledAssistant.question - && currentQuestion - && latestSettledAssistant.question.focus_id === currentQuestion.focus_id - && !questionIsAnswered(latestSettledAssistant.question) - && !deadChoice, - ); - // 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 repair button) — never bare copy telling the - // reader to wait for the server. Collect-waiting is the training-gate gap: - // no next card, keep the case open, do not show 「没有拿到下一个问题」. - const collectWaiting = interviewCollectWaiting({ - stopReason: interviewStopReason, - sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, - questionMissing: currentQuestion === null, - offeringCards: showSelectionCards, - }); - // BUG-917: the same judgement the message entry makes, read off the latest - // settled assistant message: a tap question nobody can answer any more. - const unansweredDeadChoiceOnMessages = Boolean( - latestSettledAssistant?.question - && (latestSettledAssistant.question.kind === "choice" - || latestSettledAssistant.question.kind === "reverse_verify") - && !latestSettledAssistant.question.answer_option - && (latestSettledAssistant.question.status === "active" - || latestSettledAssistant.question.status === "superseded") - && !liveQuestionOnMessages, - ); - const questionGap = rectificationQuestionGapState({ - liveQuestionVisible: liveQuestionOnMessages, - unansweredDeadChoice: unansweredDeadChoiceOnMessages, - questionMissing: currentQuestion === null, - questionLoadFailed: questionSource === "unavailable" || deadChoice, - questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, - offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null, - nextUserActionId, - collectWaiting: collectWaiting && !deadChoice, - sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, - stopReason: interviewStopReason, - deadChoice, - questionId: currentQuestion?.question_id ?? null, - hasChoiceCard: Boolean(choiceCard), - busy, - readonly, - regenerating: regeneratingMessageKey !== null, - snapshotLoaded: caseSnapshotLoaded, - resumableCase, - retryAttempts: questionRetryAttempts, - retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, - }); - // BUG-1046 (D3): the persisted question is drawn once. With an assistant - // message to carry it, it hangs there (BUG-678's intent) and other copies of - // the same focus come off; the standalone block is only the fallback when - // nothing can carry it. The gap state itself is unchanged. - const persistedTurnQuestion = questionGap === "persisted_question" - ? turnQuestionFromCurrent(currentQuestion, choiceCard) - : null; - const placedQuestion = persistedTurnQuestion - ? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage) - : null; - const placedByKey = placedQuestion && placedQuestion.messages !== messages - ? new Map(placedQuestion.messages.map((message) => [message.renderKey, message])) - : null; - const showStandaloneQuestion = questionGap === "persisted_question" - && (placedQuestion ? placedQuestion.standalone : true); - const verifiedIdleCopy = questionGap === "verified_idle" - ? postAdoptVerifyDoneCopy( - savedTime, - messages.some((message) => message.question?.kind === "reverse_verify"), - ) - : null; - const conversationState = rectificationConversationState({ - messageCount: messages.length, - busy, - readonly, - shouldStartOpening, - openingStarted: openingRequested, - }); // 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 repair button. Attempts reset once a question arrives @@ -1697,54 +474,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, }); async function refetchQuestion() { - await loadCaseSnapshot((turns) => { - setMessages((current) => applySnapshotTurnsToMessages( - current, - turns, - (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), - )); - }); + await refetchRectificationQuestion({ loadCaseSnapshot, setMessages }); } async function repairQuestion() { - if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return; - setQuestionRepairing(true); - try { - const response = await fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`, - { - method: "POST", - headers: { "content-type": "application/json" }, - cache: "no-store", - body: JSON.stringify({ sessionId }), - }, - ); - const payload = await response.json().catch(() => null); - if (response.ok && payload) { - startTransition(() => { - applyCaseSnapshot(payload); - }); - const recovered = Boolean( - currentQuestionFromSnapshot(payload.current_question) - || parseRectificationChoiceCard(payload.choice_card) - || payload.case?.accepted_time - || payload.case?.confirmed_time - ); - if (!recovered) setQuestionRepairAttempts((current) => current + 1); - } else { - await loadCaseSnapshot((turns) => { - setMessages((current) => applySnapshotTurnsToMessages( - current, - turns, - (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), - )); - }); - setQuestionRepairAttempts((current) => current + 1); - } - } catch { - setQuestionRepairAttempts((current) => current + 1); - } finally { - setQuestionRepairing(false); - } + await repairRectificationQuestion({ + caseId, + sessionId, + questionRepairing, + questionRepairAttempts, + setQuestionRepairing, + setQuestionRepairAttempts, + applyCaseSnapshot, + loadCaseSnapshot, + setMessages, + }); } // A Case with no turns and no automatic first turn (the server never starts @@ -1894,70 +637,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); })} - {showStandaloneQuestion && currentQuestion?.prompt && ( -
-
- {persistedQuestionSurface({ - questionKind: currentQuestion.kind, - hasChoiceCard: Boolean(choiceCard), - questionPersisted: true, - }) === "choice_card" && choiceCard ? ( - <> -

{currentQuestion.prompt}

- - - ) : ( -

{currentQuestion.prompt}

- )} -
-
- )} - {questionGap === "preparing" && ( -
- -
- )} - {questionGap === "delivered" && !showSelectionCards && ( -

- {RECTIFICATION_DELIVERED_COPY} -

- )} - {snapshotFailure && ( -
-

{RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY}

- -
- )} - {questionGap === "unavailable" && !snapshotFailure && ( -
- {questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? ( -

{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}

- ) : ( - <> -

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

- - - )} -
- )} - {questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && ( -

- {verifiedIdleCopy} -

- )} + {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} diff --git a/frontend/src/components/rectification-question-gap-notices.tsx b/frontend/src/components/rectification-question-gap-notices.tsx new file mode 100644 index 00000000..e3b93c8a --- /dev/null +++ b/frontend/src/components/rectification-question-gap-notices.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { + RECTIFICATION_DELIVERED_COPY, + RECTIFICATION_QUESTION_PREPARING_LABEL, + RECTIFICATION_QUESTION_RELOAD_LABEL, + RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, + RECTIFICATION_QUESTION_REPAIR_LIMIT, + RECTIFICATION_QUESTION_UNAVAILABLE_COPY, + RECTIFICATION_SNAPSHOT_RETRY_LABEL, + RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, + persistedQuestionSurface, + rectificationReadonlyRangeCopy, + type RectificationSnapshotFailure, +} from "@/lib/rectification-surface-state"; +import type { ChoiceKey, RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; +import type { RectificationChatView } from "@/lib/rectification-chat-view"; +import { Button } from "@/components/ui/button"; +import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; +import { RectificationChoiceCard } from "./rectification-choice-card"; + +export function RectificationReadonlyRange({ + range, + sessionOutcome, + discriminatingEventProbes, + contrastProbes, +}: Readonly<{ + range: readonly [string, string]; + sessionOutcome: string | null; + discriminatingEventProbes: unknown; + contrastProbes: unknown; +}>) { + return ( +

+ {rectificationReadonlyRangeCopy({ + range, + sessionOutcome, + discriminatingEventProbes, + contrastProbes, + })} +

+ ); +} + +/** + * The blocks under the transcript that stand in for a missing next question: + * the standalone persisted question (only when no assistant message can carry + * it), the preparing row, the delivered note, the snapshot-failure retry, the + * repair button, and the verified-idle note. Moved out of + * RectificationAgenticChat unchanged (TASK-rectification-code-split-20260926). + */ +export function RectificationQuestionGapNotices({ + view, + currentQuestion, + choiceCard, + choiceNonce, + busy, + readonly, + snapshotFailure, + questionRepairAttempts, + questionRepairing, + submitChoice, + submitStop, + refetchQuestion, + repairQuestion, +}: Readonly<{ + view: Pick; + currentQuestion: CurrentQuestionModel | null; + choiceCard: ChoiceCardModel | null; + choiceNonce: number; + busy: boolean; + readonly: boolean; + snapshotFailure: RectificationSnapshotFailure | null; + questionRepairAttempts: number; + questionRepairing: boolean; + submitChoice: (key: ChoiceKey) => void; + submitStop: () => void; + refetchQuestion: () => Promise; + repairQuestion: () => Promise; +}>) { + const { showStandaloneQuestion, questionGap, showSelectionCards, verifiedIdleCopy } = view; + return ( + <> + {showStandaloneQuestion && currentQuestion?.prompt && ( +
+
+ {persistedQuestionSurface({ + questionKind: currentQuestion.kind, + hasChoiceCard: Boolean(choiceCard), + questionPersisted: true, + }) === "choice_card" && choiceCard ? ( + <> +

{currentQuestion.prompt}

+ + + ) : ( +

{currentQuestion.prompt}

+ )} +
+
+ )} + {questionGap === "preparing" && ( +
+ +
+ )} + {questionGap === "delivered" && !showSelectionCards && ( +

+ {RECTIFICATION_DELIVERED_COPY} +

+ )} + {snapshotFailure && ( +
+

{RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY}

+ +
+ )} + {questionGap === "unavailable" && !snapshotFailure && ( +
+ {questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? ( +

{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}

+ ) : ( + <> +

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

+ + + )} +
+ )} + {questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && ( +

+ {verifiedIdleCopy} +

+ )} + + ); +} diff --git a/frontend/src/hooks/use-rectification-board-layout.ts b/frontend/src/hooks/use-rectification-board-layout.ts new file mode 100644 index 00000000..9eccce5f --- /dev/null +++ b/frontend/src/hooks/use-rectification-board-layout.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { + diffRectificationBoard, + RECTIFICATION_BOARD_SPLIT_MIN_PX, +} from "@/lib/rectification-board-model"; +import type { CandidateResult } from "@/lib/rectification-chat-snapshot"; + +/** + * Board layout of the rectification surface: compact (drawer) vs split view, + * the drawer's open state, and the change highlight between two candidate + * results. Moved out of RectificationAgenticChat unchanged + * (TASK-rectification-code-split-20260926). + */ +export function useRectificationBoardLayout(candidateResult: CandidateResult) { + const previousBoardResult = useRef(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), []); + + return { + compactBoard, + boardOpen, + boardDiff, + boardId, + boardTitleId, + closeBoard, + toggleBoard, + }; +} diff --git a/frontend/src/hooks/use-rectification-case-snapshot.ts b/frontend/src/hooks/use-rectification-case-snapshot.ts new file mode 100644 index 00000000..ef001ad4 --- /dev/null +++ b/frontend/src/hooks/use-rectification-case-snapshot.ts @@ -0,0 +1,211 @@ +"use client"; + +import { startTransition, useCallback, useLayoutEffect, useRef, useState } from "react"; +import { + parseRectificationCandidateResult, +} from "@/lib/rectification-candidate-result"; +import { + isAbortError, + searchWindowFromSnapshot, + caseStageFromSnapshot, + interviewStopReasonFromSnapshot, + interviewSessionOutcomeFromSnapshot, + birthTimeSourceFromSnapshot, + type RectificationCaseSnapshotPayload, + type RectificationSnapshotFailure, +} from "@/lib/rectification-surface-state"; +import type { RectificationTimelineStage } from "@/lib/rectification-timeline-scale"; +import { isRectificationCaseStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; +import { parseRectificationChoiceCard, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import { snapshotTurns } from "@/lib/rectification-chat-messages"; +import { + caseSnapshotState, + currentQuestionFromSnapshot, + questionSourceFromSnapshot, + type CandidateResult, + type CurrentQuestionModel, +} from "@/lib/rectification-chat-snapshot"; + +/** + * Case snapshot sync for the rectification chat surface: the state that the + * persisted Candidate Snapshot API owns (candidate, current question, choice + * card, saved time, timeline window / stage, interview outcome), the + * question-recovery counters that reset when a question arrives, and the one + * snapshot read in flight. Moved out of RectificationAgenticChat unchanged + * (TASK-rectification-code-split-20260926). + */ +export function useRectificationCaseSnapshot({ + caseId, + sessionId, + initialSnapshot, +}: Readonly<{ + caseId: string; + sessionId: string; + initialSnapshot: RectificationCaseSnapshotPayload | null; +}>) { + const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null); + const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true); + const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); + const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); + const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); + const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); + const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); + const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); + const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); + const [searchWindow, setSearchWindow] = useState(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null); + const [caseStage, setCaseStage] = useState(() => caseSnapshotState(initialSnapshot)?.stage ?? null); + const [nextUserActionId, setNextUserActionId] = useState(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null); + const [interviewStopReason, setInterviewStopReason] = useState(() => caseSnapshotState(initialSnapshot)?.interviewStopReason ?? null); + const [interviewSessionOutcome, setInterviewSessionOutcome] = useState(() => caseSnapshotState(initialSnapshot)?.interviewSessionOutcome ?? null); + const [birthTimeSource, setBirthTimeSource] = useState(() => caseSnapshotState(initialSnapshot)?.birthTimeSource ?? birthTimeSourceFromSnapshot(initialSnapshot)); + const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); + const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); + const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0); + const [questionRepairing, setQuestionRepairing] = useState(false); + const [snapshotFailure, setSnapshotFailure] = useState(null); + const snapshotAbort = useRef(null); + const currentQuestionRef = useRef(currentQuestion); + + useLayoutEffect(() => { + currentQuestionRef.current = currentQuestion; + }, [currentQuestion]); + + // Candidate snapshot comes from the persisted Candidate Snapshot API, never + // from parsing agent text or hidden sentinels. + const applyCaseSnapshot = useCallback((payload: RectificationCaseSnapshotPayload | { + latest_result?: unknown; + current_question?: unknown; + choice_card?: unknown; + step_state?: unknown; + turns?: unknown; + question_source?: unknown; + next_user_action?: { id?: unknown }; + interview?: { stop_reason?: unknown; session_outcome?: unknown }; + case?: { + status?: unknown; + accepted_time?: unknown; + confirmed_time?: unknown; + candidate_range?: unknown; + stage?: unknown; + }; + } | null) => { + if (!payload) return; + const snapshot = payload as RectificationCaseSnapshotPayload; + setResultNotice(snapshot.case?.result_notice ?? null); + setCanRecompare(snapshot.case?.result_identity?.can_recompare === true); + 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; + const nextActionId = typeof payload.next_user_action?.id === "string" + ? payload.next_user_action.id.trim() + : ""; + // Only overwrite when the payload actually carries them: a snapshot without + // a window should leave the timeline showing the last known one rather than + // dropping to the skeleton mid-session. + const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range); + if (nextWindow) setSearchWindow(nextWindow); + const nextStage = caseStageFromSnapshot(payload.case?.stage); + if (nextStage) setCaseStage(nextStage); + setCandidateResult(nextCandidate); + setCurrentQuestion(nextQuestion); + setQuestionSource(questionSourceFromSnapshot(payload.question_source)); + setChoiceCard(nextChoice); + setCaseStatus(nextCaseStatus); + setNextUserActionId(nextActionId || null); + setInterviewStopReason(interviewStopReasonFromSnapshot(snapshot)); + setInterviewSessionOutcome(interviewSessionOutcomeFromSnapshot(snapshot)); + setBirthTimeSource(birthTimeSourceFromSnapshot(snapshot)); + setCaseSnapshotLoaded(true); + if (nextQuestion || nextChoice || acceptedTime || confirmedTime) { + setQuestionRepairAttempts(0); + } + if (confirmedTime) { + setSavedTime(confirmedTime); + setSavedStatus("confirmed"); + } else if (acceptedTime) { + setSavedTime(acceptedTime); + setSavedStatus("accepted"); + } + }, []); + + const loadCaseSnapshot = useCallback(async ( + mergeMessages?: (turns: readonly unknown[]) => void, + ): Promise<{ + question: CurrentQuestionModel | null; + turns: readonly unknown[]; + } | null | undefined> => { + snapshotAbort.current?.abort(); + const controller = new AbortController(); + snapshotAbort.current = controller; + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, + { cache: "no-store", signal: controller.signal }, + ); + if (controller.signal.aborted) return undefined; + if (!response.ok) { + setSnapshotFailure({ status: response.status }); + return undefined; + } + const payload = await response.json().catch(() => null); + if (controller.signal.aborted) return undefined; + const turns = snapshotTurns(payload); + startTransition(() => { + setSnapshotFailure(null); + applyCaseSnapshot(payload); + mergeMessages?.(turns); + }); + return { + question: currentQuestionFromSnapshot(payload?.current_question), + turns, + }; + } catch (error) { + if (isAbortError(error) || controller.signal.aborted) return undefined; + setSnapshotFailure({ status: "network" }); + return undefined; + } finally { + if (snapshotAbort.current === controller) snapshotAbort.current = null; + } + }, [applyCaseSnapshot, caseId, sessionId]); + + return { + resultNotice, + canRecompare, + savedTime, + setSavedTime, + savedStatus, + setSavedStatus, + candidateResult, + setCandidateResult, + choiceCard, + currentQuestion, + questionSource, + caseStatus, + searchWindow, + caseStage, + nextUserActionId, + setNextUserActionId, + interviewStopReason, + interviewSessionOutcome, + birthTimeSource, + caseSnapshotLoaded, + questionRetryAttempts, + setQuestionRetryAttempts, + questionRepairAttempts, + setQuestionRepairAttempts, + questionRepairing, + setQuestionRepairing, + snapshotFailure, + snapshotAbort, + currentQuestionRef, + applyCaseSnapshot, + loadCaseSnapshot, + }; +} + +export type RectificationCaseSnapshotSync = ReturnType; diff --git a/frontend/src/hooks/use-rectification-live-activity.ts b/frontend/src/hooks/use-rectification-live-activity.ts new file mode 100644 index 00000000..d50c45e1 --- /dev/null +++ b/frontend/src/hooks/use-rectification-live-activity.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import { rectificationLiveProgressLabel } from "@/lib/rectification-activity-labels"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +/** + * The live working row's label clock: when the run and the current step + * started, which tool is running, and the 4-second tick that refreshes the + * elapsed-time label while a turn is busy. Moved out of + * RectificationAgenticChat unchanged (TASK-rectification-code-split-20260926). + */ +export function useRectificationLiveActivity({ + busy, + setMessages, +}: Readonly<{ + busy: boolean; + setMessages: Dispatch>; +}>) { + const runStartedAtRef = useRef(0); + const stepStartedAtRef = useRef(0); + const liveToolRef = useRef(null); + const liveBaseLabelRef = useRef("正在处理…"); + + const beginLiveRun = useCallback((baseLabel: string) => { + const now = Date.now(); + runStartedAtRef.current = now; + stepStartedAtRef.current = now; + liveToolRef.current = null; + liveBaseLabelRef.current = baseLabel; + }, []); + + const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => { + if (liveBaseLabelRef.current !== baseLabel) { + liveBaseLabelRef.current = baseLabel; + stepStartedAtRef.current = Date.now(); + } + liveToolRef.current = tool; + return rectificationLiveProgressLabel({ + tool, + baseLabel, + stepStartedAt: stepStartedAtRef.current, + runStartedAt: runStartedAtRef.current, + }); + }, []); + + useEffect(() => { + if (!busy) return; + const id = window.setInterval(() => { + const label = rectificationLiveProgressLabel({ + tool: liveToolRef.current, + baseLabel: liveBaseLabelRef.current, + stepStartedAt: stepStartedAtRef.current, + runStartedAt: runStartedAtRef.current, + }); + setMessages((current) => current.map((message) => { + if (message.state !== "thinking" && message.state !== "streaming") return message; + if (!message.activity || message.activity.label === label) return message; + return { ...message, activity: { ...message.activity, label } }; + })); + }, 4000); + return () => window.clearInterval(id); + }, [busy]); + + return { beginLiveRun, rememberLiveActivity }; +} diff --git a/frontend/src/lib/rectification-chat-accept-run.ts b/frontend/src/lib/rectification-chat-accept-run.ts new file mode 100644 index 00000000..d29d8414 --- /dev/null +++ b/frontend/src/lib/rectification-chat-accept-run.ts @@ -0,0 +1,183 @@ +/** + * Adopting a candidate minute from the range delivery card: one live row for + * the accept POST and the agent turn that follows on it, the working house + * table recast from the adopted minute, and the stop / fail branches. Moved + * out of RectificationAgenticChat's `acceptCandidate` unchanged + * (TASK-rectification-code-split-20260926). A parameter function: the + * component hands in its state, setters and refs; no React hooks here. + */ +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { emptyActivityTrace } from "@/lib/agent-activity-trace"; +import { + natalRecastMeaning, + workingRectificationHouseTable, +} from "@/lib/rectification-candidate-result"; +import { + rectificationAdoptingLabel, + isAbortError, +} from "@/lib/rectification-surface-state"; +import type { CandidateResult } from "@/lib/rectification-chat-snapshot"; +import type { + RectificationRunOutcome, + RectificationSnapshotLoader, + RectificationTurnAction, + RectificationTurnContinuation, +} from "@/lib/rectification-chat-turn-run"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationCandidateAcceptDeps = Readonly<{ + caseId: string; + sessionId: string; + busy: boolean; + readonly: boolean; + candidateResult: CandidateResult; + acceptingCandidateId: string | null; + keyCounter: MutableRefObject; + runAbort: MutableRefObject; + lastRunOutcome: MutableRefObject; + conversationAnchor: Readonly<{ pinLatestTurn: () => void }>; + setError: Dispatch>; + setMessages: Dispatch>; + setAcceptingCandidateId: Dispatch>; + setCandidateResult: Dispatch>; + setSavedTime: Dispatch>; + setSavedStatus: Dispatch>; + setPending: (value: boolean) => void; + beginLiveRun: (baseLabel: string) => void; + loadCaseSnapshot: RectificationSnapshotLoader; + send: ( + action: RectificationTurnAction, + messageText: string, + continuation?: RectificationTurnContinuation, + ) => Promise; + onCompleted?: () => void; + onSaved?: (time: string, status: "accepted" | "confirmed") => void; +}>; + +export async function runRectificationCandidateAccept( + deps: RectificationCandidateAcceptDeps, + candidateId: string, +): Promise { + const { + caseId, + sessionId, + busy, + readonly, + candidateResult, + acceptingCandidateId, + keyCounter, + runAbort, + lastRunOutcome, + conversationAnchor, + setError, + setMessages, + setAcceptingCandidateId, + setCandidateResult, + setSavedTime, + setSavedStatus, + setPending, + beginLiveRun, + loadCaseSnapshot, + send, + onCompleted, + onSaved, + } = deps; + if (!candidateResult || acceptingCandidateId || busy || readonly) return; + setError(""); + setAcceptingCandidateId(candidateId); + setPending(true); + // Adoption is a server round trip followed by an agent turn; the reader sees + // one live row for the whole of it instead of a greyed button. + const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; + const adoptingLabel = rectificationAdoptingLabel(candidateTime); + keyCounter.current += 1; + const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; + beginLiveRun(adoptingLabel); + setMessages((current) => [ + ...current, + { + role: "assistant", + text: "", + renderKey: assistantRenderKey, + state: "thinking", + activityTrace: emptyActivityTrace(), + activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, + }, + ]); + conversationAnchor.pinLatestTurn(); + const abortController = new AbortController(); + runAbort.current = abortController; + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, + { + method: "POST", + headers: { "content-type": "application/json" }, + signal: abortController.signal, + body: JSON.stringify({ + sessionId, + resultId: candidateResult.resultId, + candidateId, + requestId: globalThis.crypto.randomUUID(), + mode: "range", + }), + }, + ); + 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(); + await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); + } catch (caught) { + if (isAbortError(caught)) { + lastRunOutcome.current = "stopped"; + setMessages((current) => current.map((message) => ( + message.renderKey !== assistantRenderKey ? message : { + ...message, + state: "settled" as const, + failed: false, + stopped: true, + activity: undefined, + } + ))); + return; + } + lastRunOutcome.current = "failed"; + setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); + setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); + } finally { + if (runAbort.current === abortController) runAbort.current = null; + if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; + setAcceptingCandidateId(null); + setPending(false); + } +} diff --git a/frontend/src/lib/rectification-chat-choice-run.ts b/frontend/src/lib/rectification-chat-choice-run.ts new file mode 100644 index 00000000..4ad0ecb7 --- /dev/null +++ b/frontend/src/lib/rectification-chat-choice-run.ts @@ -0,0 +1,283 @@ +/** + * A tapped choice (answer, stop, skip this probe) from the rectification chat: + * the local answered mark and live row, the structured POST, the transcript + * merge after the Case re-read, the follow-up turn on the same row, and the + * roll-back when the tap did not land (BUG-1046). Moved out of + * RectificationAgenticChat's `submitStructuredChoice` unchanged + * (TASK-rectification-code-split-20260926). A parameter function: the + * component hands in its state, setters and refs; no React hooks here. + */ +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import type { ChatMessage } from "@/lib/chat-message-view"; +import { emptyActivityTrace } from "@/lib/agent-activity-trace"; +import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS } from "@/lib/rectification-activity-labels"; +import { + RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY, + isAbortError, +} from "@/lib/rectification-surface-state"; +import { + CHOICE_ACTION, + SKIP_PROBE_ACTION, + STOP_ACTION, + shouldContinueAfterStructuredChoice, + type ChoiceOptionId, +} from "@/lib/rectification-agentic/v9/choice-action"; +import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import { + appendUnseenAssistantTurns, + applySnapshotTurnsToMessages, + mergeTurnQuestions, +} from "@/lib/rectification-snapshot-messages"; +import { + markQuestionAnswered, + messagesFromTurns, + withdrawAnswerMark, + type PersistedTurn, +} from "@/lib/rectification-chat-messages"; +import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; +import type { + RectificationRunOutcome, + RectificationSnapshotLoader, + RectificationTurnAction, + RectificationTurnContinuation, +} from "@/lib/rectification-chat-turn-run"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationStructuredChoiceAction = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION; +export type RectificationStructuredChoiceOverride = Readonly<{ + focusId?: string; + questionId?: string | null; + probeId?: string | null; + caseRevision?: number | null; +}>; + +export type RectificationStructuredChoiceDeps = Readonly<{ + caseId: string; + sessionId: string; + busy: boolean; + readonly: boolean; + choiceCard: ChoiceCardModel | null; + currentQuestion: CurrentQuestionModel | null; + keyCounter: MutableRefObject; + runAbort: MutableRefObject; + lastRunOutcome: MutableRefObject; + conversationAnchor: Readonly<{ pinLatestTurn: () => void }>; + actionIdForChoice: (focusId: string, optionId: ChoiceOptionId) => string; + setError: Dispatch>; + setMessages: Dispatch>; + setChoiceNonce: Dispatch>; + setNextUserActionId: Dispatch>; + setPending: (value: boolean) => void; + beginLiveRun: (baseLabel: string) => void; + loadCaseSnapshot: RectificationSnapshotLoader; + send: ( + action: RectificationTurnAction, + messageText: string, + continuation?: RectificationTurnContinuation, + ) => Promise; + onCompleted?: () => void; + onMessagesChange?: (messages: ChatMessage[]) => void; + onProfileIncomplete?: () => void; +}>; + +export async function runRectificationStructuredChoice( + deps: RectificationStructuredChoiceDeps, + action: RectificationStructuredChoiceAction, + optionId: ChoiceKey | "stop" | "skip_probe", + override?: RectificationStructuredChoiceOverride, +): Promise { + const { + caseId, + sessionId, + busy, + readonly, + choiceCard, + currentQuestion, + keyCounter, + runAbort, + lastRunOutcome, + conversationAnchor, + actionIdForChoice, + setError, + setMessages, + setChoiceNonce, + setNextUserActionId, + setPending, + beginLiveRun, + loadCaseSnapshot, + send, + onCompleted, + onMessagesChange, + onProfileIncomplete, + } = deps; + const focusId = override?.focusId ?? choiceCard?.focus_id ?? ( + action === STOP_ACTION && currentQuestion?.kind === "collect_spoken" + ? currentQuestion.focus_id + : null + ); + if (busy || readonly) return; + if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return; + if (focusId && !isPersistedFocusId(focusId)) { + setError("当前选择题已失效,请等待下一问。"); + return; + } + const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null; + const probeId = override?.probeId ?? choiceCard?.probe_id; + const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0; + const actionId = actionIdForChoice(focusId ?? caseId, optionId); + setError(""); + keyCounter.current += 1; + const turnKey = keyCounter.current; + const assistantRenderKey = `v9-choice-assistant-${turnKey}`; + const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; + beginLiveRun(recordingLabel); + setMessages((current) => [ + ...markQuestionAnswered(current, focusId ?? "", optionId), + { + role: "assistant", + text: "", + renderKey: assistantRenderKey, + state: "thinking", + activityTrace: emptyActivityTrace(), + activity: { + phase: "evidence-validation", + label: recordingLabel, + startedAt: Date.now(), + }, + }, + ]); + conversationAnchor.pinLatestTurn(); + setPending(true); + // BUG-1046 (D2): a tap that did not land takes back its local answered + // mark and placeholder; the card key changes (answer_option → nonce), so + // the card remounts clean and one tappable card is left. + const rollBackFailedChoice = () => { + setMessages((current) => withdrawAnswerMark( + current.filter((message) => message.renderKey !== assistantRenderKey), + focusId ?? "", + optionId, + )); + setChoiceNonce((current) => current + 1); + }; + // The server may have moved on (stale_question, a dropped response after + // it applied): re-read the Case so the card reflects what it holds. + const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); + 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: actionId, + action, + actionId, + ...(focusId ? { focusId } : {}), + ...(questionId ? { questionId } : {}), + probeId: probeId ?? null, + optionId: optionId === "stop" ? undefined : optionId, + expectedRevision, + origin: "choice_click", + clientActionId: actionId, + }), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + rollBackFailedChoice(); + if (payload?.code === "profile_incomplete") { + onProfileIncomplete?.(); + return; + } + setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); + await reloadAfterFailedChoice(); + return; + } + const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); + const payloadNextActionId = typeof payload?.next_user_action?.id === "string" + ? payload.next_user_action.id.trim() + : ""; + if (payloadNextActionId) setNextUserActionId(payloadNextActionId); + onCompleted?.(); + await loadCaseSnapshot((turns) => { + if (willContinue) { + // D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn + // the server wrote while applying the tap joins the transcript, + // above the row the follow-up run continues on. + setMessages((current) => mergeTurnQuestions( + appendUnseenAssistantTurns( + current, + messagesFromTurns(turns as readonly PersistedTurn[]), + { beforeRenderKey: assistantRenderKey }, + ), + turns, + )); + return; + } + const narration = typeof payload?.narration === "string" && payload.narration.trim() + ? payload.narration.trim() + : "已记录你的选择。"; + setMessages((current) => { + const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); + const withHistory = appendUnseenAssistantTurns( + mergeTurnQuestions(withoutPlaceholder, turns), + messagesFromTurns(turns as readonly PersistedTurn[]), + ); + if (withHistory.length > withoutPlaceholder.length) return withHistory; + return [ + ...withHistory, + { + role: "assistant" as const, + text: narration, + renderKey: assistantRenderKey, + state: "settled" as const, + activity: undefined, + }, + ]; + }); + }); + if (willContinue) { + // The follow-up turn continues on the row already in place: no removed + // row, no effect hop, and `busy` never drops in between (so the next + // card cannot flash before the turn hides it). + await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); + } else { + const narration = typeof payload?.narration === "string" && payload.narration.trim() + ? payload.narration.trim() + : "已记录你的选择。"; + onMessagesChange?.([ + { role: "assistant", text: narration }, + ]); + } + } catch (caught) { + if (isAbortError(caught)) { + lastRunOutcome.current = "stopped"; + setMessages((current) => current.map((message) => ( + message.renderKey !== assistantRenderKey ? message : { + ...message, + state: "settled" as const, + failed: false, + stopped: true, + activity: undefined, + } + ))); + return; + } + lastRunOutcome.current = "failed"; + rollBackFailedChoice(); + setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); + await reloadAfterFailedChoice(); + } finally { + if (runAbort.current === abortController) runAbort.current = null; + if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; + setPending(false); + } +} diff --git a/frontend/src/lib/rectification-chat-message-actions.ts b/frontend/src/lib/rectification-chat-message-actions.ts new file mode 100644 index 00000000..10ed0cb4 --- /dev/null +++ b/frontend/src/lib/rectification-chat-message-actions.ts @@ -0,0 +1,88 @@ +/** + * Per-message actions of the rectification chat: copy (question included) and + * regenerate the latest settled answer. Moved out of RectificationAgenticChat + * unchanged (TASK-rectification-code-split-20260926). Parameter functions; + * no React hooks here. + */ +import type { Dispatch, SetStateAction } from "react"; +import { copyTextForMessage } from "@/lib/rectification-agentic/v9/turn-question"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationCopyMessageDeps = Readonly<{ + setCopiedMessageKey: Dispatch>; +}>; + +export async function copyRectificationMessage( + deps: RectificationCopyMessageDeps, + message: RenderMessage, +): Promise { + const { setCopiedMessageKey } = deps; + try { + await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question)); + setCopiedMessageKey(message.renderKey); + window.setTimeout(() => setCopiedMessageKey((current) => ( + current === message.renderKey ? null : current + )), 1_500); + } catch { + // Clipboard permission failures must not interrupt the conversation. + } +} + +export type RectificationRegenerateMessageDeps = Readonly<{ + caseId: string; + sessionId: string; + busy: boolean; + readonly: boolean; + regeneratingMessageKey: string | null; + setError: Dispatch>; + setMessages: Dispatch>; + setRegeneratingMessageKey: Dispatch>; +}>; + +export async function regenerateRectificationMessage( + deps: RectificationRegenerateMessageDeps, + message: RenderMessage, +): Promise { + const { + caseId, + sessionId, + busy, + readonly, + regeneratingMessageKey, + setError, + setMessages, + setRegeneratingMessageKey, + } = deps; + 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 || "暂时无法重新生成回答"); + } + const nextText = payload.assistantMessage; + setMessages((current) => current.map((item) => item.renderKey === message.renderKey + ? { ...item, text: nextText, 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); + } +} diff --git a/frontend/src/lib/rectification-chat-messages.ts b/frontend/src/lib/rectification-chat-messages.ts new file mode 100644 index 00000000..23cf81f4 --- /dev/null +++ b/frontend/src/lib/rectification-chat-messages.ts @@ -0,0 +1,163 @@ +/** + * Pure transcript helpers of the rectification chat surface: persisted turns + * to render messages, the local "answered" mark on a question and its undo, + * and the persisted receipt view. Moved out of rectification-agentic-chat.tsx + * unchanged (TASK-rectification-code-split-20260926); no React hooks here. + */ +import { activityTraceFromReceipt } from "@/lib/rectification-activity-labels"; +import { + createRectificationActivityReceiptState, + receiptFromRectificationActivityState, + reduceRectificationActivityReceipt, + type CompletedActivityReceiptView, +} from "@/lib/rectification-activity-receipt"; +import { + isPublicRectificationMethod, + isPublicRectificationTool, +} from "@/lib/rectification-agentic/v9/public-receipt"; +import { isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; +import { isStructuredChoiceUserText } from "@/lib/rectification-agentic/v9/choice-action"; +import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card"; +import { parseTurnQuestion, questionIsAnswered } from "@/lib/rectification-agentic/v9/turn-question"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type PersistedTurn = Readonly<{ + id: string; + role: "user" | "assistant"; + text: string | null; + status: string; + question?: unknown; + offer_result_id?: string | null; + receipt?: Readonly<{ + status: string; + phases: readonly string[]; + tool_activities?: readonly Readonly<{ + tool: string; + status: string; + methods?: readonly string[]; + started_at?: string | null; + elapsed_ms?: number | null; + detail?: Readonly> | null; + }>[]; + tools: readonly string[]; + methods?: readonly string[]; + skill_name?: string; + skill_version?: string; + }> | null; +}>; + +export function markQuestionAnswered( + current: RenderMessage[], + focusId: string, + selected: ChoiceKey | "stop" | "skip_probe" | "typed", +): RenderMessage[] { + return current.map((message) => { + const question = message.question; + if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message; + return { + ...message, + question: { + ...question, + status: "resolved", + answer_option: selected === "typed" ? null : selected, + }, + }; + }); +} + +/** + * BUG-1046: undo `markQuestionAnswered` when the answer never reached the + * server, so the question is active again with no option picked. Only the mark + * this action made is undone; a server-resolved question is left alone. + */ +export function withdrawAnswerMark( + current: RenderMessage[], + focusId: string, + selected: ChoiceKey | "stop" | "skip_probe" | "typed", +): RenderMessage[] { + const marked = selected === "typed" ? null : selected; + return current.map((message) => { + const question = message.question; + if (!question || question.focus_id !== focusId) return message; + if (question.status !== "resolved" || question.answer_option !== marked) return message; + return { ...message, question: { ...question, status: "active", answer_option: null } }; + }); +} + +export function isSettledAssistantMessage(message: RenderMessage): boolean { + return message.role === "assistant" && message.state === "settled" && !message.failed; +} + +export function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] { + return Array.isArray(payload?.turns) ? payload.turns : []; +} + +export 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 hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean { + return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool); +} + +export function persistedTurnFailed(turn: PersistedTurn): boolean { + return turn.status === "failed" + || turn.status === "retryable" + || turn.receipt?.status === "failed" + || turn.receipt?.status === "degraded" + || turn.receipt?.status === "blocked"; +} + +export 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, + question: parseTurnQuestion(turn.question) ?? undefined, + candidateOffer: typeof turn.offer_result_id === "string" + ? { resultId: turn.offer_result_id } + : undefined, + }]; + } + if (isIncompleteRunBanner(turn.text ?? "")) return []; + if (isStructuredChoiceUserText(turn.text)) return []; + return [{ + role: "user", + text: turn.text ?? "", + renderKey: key, + state: "settled", + }]; + }); +} diff --git a/frontend/src/lib/rectification-chat-question-repair.ts b/frontend/src/lib/rectification-chat-question-repair.ts new file mode 100644 index 00000000..14b13e4e --- /dev/null +++ b/frontend/src/lib/rectification-chat-question-repair.ts @@ -0,0 +1,99 @@ +/** + * Question recovery of the rectification chat: re-read the Case while the + * next question is `preparing`, and the one-button repair when it is + * `unavailable`. Moved out of RectificationAgenticChat unchanged + * (TASK-rectification-code-split-20260926). Parameter functions; the timer + * itself stays in the component (useVisibilityAwarePoll). No React hooks here. + */ +import { startTransition, type Dispatch, type SetStateAction } from "react"; +import { + RECTIFICATION_QUESTION_REPAIR_LIMIT, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; +import { parseRectificationChoiceCard } from "@/lib/rectification-agentic/v9/choice-card"; +import { applySnapshotTurnsToMessages } from "@/lib/rectification-snapshot-messages"; +import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages"; +import { currentQuestionFromSnapshot } from "@/lib/rectification-chat-snapshot"; +import type { RectificationSnapshotLoader } from "@/lib/rectification-chat-turn-run"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationQuestionRefetchDeps = Readonly<{ + loadCaseSnapshot: RectificationSnapshotLoader; + setMessages: Dispatch>; +}>; + +export async function refetchRectificationQuestion(deps: RectificationQuestionRefetchDeps): Promise { + const { loadCaseSnapshot, setMessages } = deps; + await loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); +} + +export type RectificationQuestionRepairDeps = Readonly<{ + caseId: string; + sessionId: string; + questionRepairing: boolean; + questionRepairAttempts: number; + setQuestionRepairing: Dispatch>; + setQuestionRepairAttempts: Dispatch>; + applyCaseSnapshot: (payload: RectificationCaseSnapshotPayload | null) => void; + loadCaseSnapshot: RectificationSnapshotLoader; + setMessages: Dispatch>; +}>; + +export async function repairRectificationQuestion(deps: RectificationQuestionRepairDeps): Promise { + const { + caseId, + sessionId, + questionRepairing, + questionRepairAttempts, + setQuestionRepairing, + setQuestionRepairAttempts, + applyCaseSnapshot, + loadCaseSnapshot, + setMessages, + } = deps; + if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return; + setQuestionRepairing(true); + try { + const response = await fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`, + { + method: "POST", + headers: { "content-type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ sessionId }), + }, + ); + const payload = await response.json().catch(() => null); + if (response.ok && payload) { + startTransition(() => { + applyCaseSnapshot(payload); + }); + const recovered = Boolean( + currentQuestionFromSnapshot(payload.current_question) + || parseRectificationChoiceCard(payload.choice_card) + || payload.case?.accepted_time + || payload.case?.confirmed_time + ); + if (!recovered) setQuestionRepairAttempts((current) => current + 1); + } else { + await loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); + setQuestionRepairAttempts((current) => current + 1); + } + } catch { + setQuestionRepairAttempts((current) => current + 1); + } finally { + setQuestionRepairing(false); + } +} diff --git a/frontend/src/lib/rectification-chat-snapshot.ts b/frontend/src/lib/rectification-chat-snapshot.ts new file mode 100644 index 00000000..bb3f3cf7 --- /dev/null +++ b/frontend/src/lib/rectification-chat-snapshot.ts @@ -0,0 +1,94 @@ +/** + * Pure reads of the Case snapshot payload for the rectification chat surface: + * the current question, its source, and the initial state that arrives with + * the reveal. Moved out of rectification-agentic-chat.tsx unchanged + * (TASK-rectification-code-split-20260926); no React hooks here. + */ +import { + parseRectificationCandidateResult, + type RectificationCandidateResult, +} from "@/lib/rectification-candidate-result"; +import { + searchWindowFromSnapshot, + caseStageFromSnapshot, + interviewStopReasonFromSnapshot, + interviewSessionOutcomeFromSnapshot, + birthTimeSourceFromSnapshot, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; +import type { RectificationTimelineStage } from "@/lib/rectification-timeline-scale"; +import { isRectificationCaseStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; +import { parseRectificationChoiceCard, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; + +export type CandidateResult = RectificationCandidateResult | null; +export type CurrentQuestionModel = Readonly<{ + kind: "choice" | "collect_spoken"; + prompt: string | null; + focus_id: string | null; + question_id: string | null; +}>; + +export function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown }; + if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") { + return null; + } + return { + kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice", + prompt: typeof question.prompt === "string" && question.prompt.trim() + ? question.prompt.trim() + : null, + focus_id: typeof question.focus_id === "string" ? question.focus_id : null, + question_id: typeof question.question_id === "string" ? question.question_id : null, + }; +} + +export function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null { + if (value === "unavailable") return "unavailable"; + if (value === "focus") return "focus"; + return null; +} + +export type CaseSnapshotState = Readonly<{ + candidate: CandidateResult; + question: CurrentQuestionModel | null; + questionSource: "focus" | "unavailable" | null; + choice: ChoiceCardModel | null; + caseStatus: RectificationCaseStatus | null; + savedTime: string | null; + savedStatus: "accepted" | "confirmed" | null; + nextUserActionId: string | null; + searchWindow: readonly [string, string] | null; + stage: RectificationTimelineStage | null; + interviewStopReason: string | null; + interviewSessionOutcome: string | null; + birthTimeSource: ReturnType; +}>; + +export function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null { + const id = payload?.next_user_action?.id; + return typeof id === "string" && id.trim() ? id.trim() : null; +} + +/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */ +export function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null { + if (!payload) return null; + const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; + const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; + return { + candidate: parseRectificationCandidateResult(payload.latest_result), + question: currentQuestionFromSnapshot(payload.current_question), + questionSource: questionSourceFromSnapshot(payload.question_source), + choice: parseRectificationChoiceCard(payload.choice_card), + caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, + savedTime: confirmedTime ?? acceptedTime, + savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, + nextUserActionId: nextUserActionIdFromSnapshot(payload), + searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range), + stage: caseStageFromSnapshot(payload.case?.stage), + interviewStopReason: interviewStopReasonFromSnapshot(payload), + interviewSessionOutcome: interviewSessionOutcomeFromSnapshot(payload), + birthTimeSource: birthTimeSourceFromSnapshot(payload), + }; +} diff --git a/frontend/src/lib/rectification-chat-turn-run.ts b/frontend/src/lib/rectification-chat-turn-run.ts new file mode 100644 index 00000000..ad475b52 --- /dev/null +++ b/frontend/src/lib/rectification-chat-turn-run.ts @@ -0,0 +1,510 @@ +/** + * One rectification agent turn from the browser: the POST to + * /api/rectification/agent, the NDJSON stream read into the live row, the + * settle / fail / stop branches, and the snapshot re-read after the turn. + * Moved out of RectificationAgenticChat's `send` unchanged + * (TASK-rectification-code-split-20260926). A parameter function: the + * component hands in its state, setters and refs; no React hooks here. + */ +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { parseAgentReply } from "@/lib/agent-reply"; +import { nextActivityView, type AgentActivityView, type ChatMessage } 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_ANALYZING_LIVE_LABEL, + RECTIFICATION_TOOL_DONE_LABELS, + RECTIFICATION_TOOL_PROGRESS_LABELS, + RECTIFICATION_TURN_PROGRESS_LABELS, + rectificationCompletedTrail, + rectificationToolActivityPhase, + rectificationTurnProgressPhase, +} from "@/lib/rectification-activity-labels"; +import { isRectificationTurnProgressStage } from "@/lib/rectification-agentic/v9/turn-progress"; +import { withLiveStepLabel } from "@/lib/rectification-timeline-adapter"; +import { + createRectificationActivityReceiptState, + receiptFromRectificationActivityState, + reduceRectificationActivityReceipt, +} from "@/lib/rectification-activity-receipt"; +import { + RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, + isAbortError, + rectificationInitialLiveLabel, +} from "@/lib/rectification-surface-state"; +import { + isPublicRectificationActivity, + isPublicRectificationMethod, + isPublicRectificationTool, +} from "@/lib/rectification-agentic/v9/public-receipt"; +import { userFacingRunFailure } from "@/lib/rectification-agentic/v9/run-diagnostic"; +import { + applySnapshotTurnsToMessages, + mergeTurnQuestions, +} from "@/lib/rectification-snapshot-messages"; +import { + hasActivityReceipt, + markQuestionAnswered, + messagesFromTurns, + withdrawAnswerMark, + type PersistedTurn, +} from "@/lib/rectification-chat-messages"; +import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationTurnAction = "opening" | "message" | "read_only"; +export type RectificationTurnContinuation = Readonly<{ reuseAssistantRenderKey: string; label: string }>; +export type RectificationRunOutcome = "succeeded" | "stopped" | "failed" | "readonly" | null; +export type RectificationSnapshotLoader = ( + mergeMessages?: (turns: readonly unknown[]) => void, +) => Promise; + +export type RectificationChatTurnDeps = Readonly<{ + caseId: string; + sessionId: string; + selectedModelId: string; + busy: boolean; + readonly: boolean; + conversationReadonly: boolean; + keyCounter: MutableRefObject; + currentQuestionRef: MutableRefObject; + runAbort: MutableRefObject; + lastRunOutcome: MutableRefObject; + conversationAnchor: Readonly<{ pinLatestTurn: () => void }>; + setError: Dispatch>; + setMessages: Dispatch>; + setPending: (value: boolean) => void; + beginLiveRun: (baseLabel: string) => void; + rememberLiveActivity: (baseLabel: string, tool?: string | null) => string; + loadCaseSnapshot: RectificationSnapshotLoader; + onCompleted?: () => void; + onMessagesChange?: (messages: ChatMessage[]) => void; + onOpenBilling?: (options?: { source?: string }) => void; + onProfileIncomplete?: () => void; +}>; + +/** + * Run one agent turn. A `continuation` reuses the live row an earlier step + * (a tapped choice, an adopted candidate) already put at the end of the + * transcript, so the reader sees one uninterrupted working row from the tap + * to the next question instead of a gap and a re-appended row. The caller + * of a continuation already holds `busy`. + */ +export async function runRectificationChatTurn( + deps: RectificationChatTurnDeps, + action: RectificationTurnAction, + messageText: string, + continuation?: RectificationTurnContinuation, +): Promise { + const { + caseId, + sessionId, + selectedModelId, + busy, + readonly, + conversationReadonly, + keyCounter, + currentQuestionRef, + runAbort, + lastRunOutcome, + conversationAnchor, + setError, + setMessages, + setPending, + beginLiveRun, + rememberLiveActivity, + loadCaseSnapshot, + onCompleted, + onMessagesChange, + onOpenBilling, + onProfileIncomplete, + } = deps; + const trimmed = action === "message" ? messageText.trim() : ""; + if ((action === "message" && !trimmed) || conversationReadonly) return; + if (!continuation && busy) return; + setError(""); + setPending(true); + const initialLabel = rectificationInitialLiveLabel(action, continuation?.label); + beginLiveRun(initialLabel); + + keyCounter.current += 1; + const requestId = globalThis.crypto.randomUUID(); + const turnKey = keyCounter.current; + const userRenderKey = `v9-user-${turnKey}`; + const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`; + + const pendingFocusId = currentQuestionRef.current?.focus_id; + const liveRow: RenderMessage = { + role: "assistant", + text: "", + renderKey: assistantRenderKey, + state: "thinking", + activityTrace: emptyActivityTrace(), + activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() }, + }; + setMessages((current) => (continuation + ? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message)) + : [ + ...(action === "message" && pendingFocusId + ? markQuestionAnswered(current, pendingFocusId, "typed") + : current), + ...(action === "message" + ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] + : []), + liveRow, + ])); + if (!continuation) conversationAnchor.pinLatestTurn(); + // The typed answer never reached the server: the question it marked is + // still the pending one (BUG-1046, same as a failed tap). + const withdrawTypedMark = (current: RenderMessage[]) => ( + !continuation && action === "message" && pendingFocusId + ? withdrawAnswerMark(current, pendingFocusId, "typed") + : current + ); + let raw = ""; + let runOutcome: "succeeded" | "stopped" | "failed" = "failed"; + let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); + let activityReceiptState = createRectificationActivityReceiptState(); + let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); + let completedTurnId: string | undefined; + let currentActivity: AgentActivityView | undefined = { + phase: "evidence-validation", + label: initialLabel, + 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({ + 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, + }), + }); + // A turn the server turned down: the old non-2xx response, or the same + // facts as a `turn.rejected` event once the stream is already open + // (BUG-1047). Either way the live row goes and the typed mark returns. + const rejectTurn = ( + response: { status: number }, + payload: { code?: unknown; message?: unknown; error?: unknown } | null, + ) => { + const message = (typeof payload?.message === "string" && payload.message) + || (typeof payload?.error === "string" && payload.error) + || `请求失败(${response.status})`; + setMessages((current) => withdrawTypedMark( + current.filter((message) => message.renderKey !== assistantRenderKey), + )); + if (payload?.code === "profile_incomplete") { + onProfileIncomplete?.(); + return; + } + if (response.status === 402) { + // Keep the conversation on this page; the settings pane covers billing. + setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); + onOpenBilling?.({ source: "rectification" }); + return; + } + if (response.status === 401) setError("请先登录。"); + else setError(message); + }; + if (!response.ok) { + const payload = await response.json().catch(() => null); + rejectTurn(response, payload); + return; + } + if (!response.body) { + setMessages((current) => withdrawTypedMark( + 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 = ""; + let rejected: { status: number; payload: { code?: unknown; message?: unknown } } | null = null; + // Stage line of a typed answer (BUG-1047 D3); null until the first + // `turn.progress`. It replaces the generic tool / "正在分析…" labels. + let stageLabel: string | null = null; + while (!rejected) { + 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; + stage?: unknown; + httpStatus?: unknown; + }; + try { + event = JSON.parse(line) as typeof event; + } catch { + continue; + } + if (typeof event.type !== "string") continue; + if (event.type === "turn.rejected" && typeof event.httpStatus === "number") { + rejected = { status: event.httpStatus, payload: { code: event.code, message: event.message } }; + break; + } + if (event.type === "turn.progress" && isRectificationTurnProgressStage(event.stage)) { + stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS[event.stage]; + activityTrace = withLiveStepLabel(activityTrace, stageLabel); + currentActivity = nextActivityView(currentActivity, { + phase: rectificationTurnProgressPhase(event.stage), + label: rememberLiveActivity(stageLabel, null), + }); + frames.touch(); + } else 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: rememberLiveActivity("正在组织回答…"), + }); + frames.setAnswer(raw); + } else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) { + const activity = event.activity; + currentActivity = nextActivityView(currentActivity, { + phase: "evidence-validation", + label: rememberLiveActivity(stageLabel ?? RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]), + }); + frames.touch(); + } else if (event.type === "attempt.reset") { + raw = ""; + activityTrace = emptyActivityTrace(); + activityReceiptState = createRectificationActivityReceiptState(); + completedReceipt = receiptFromRectificationActivityState(activityReceiptState); + completedTurnId = undefined; + if (stageLabel) stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS.received; + currentActivity = nextActivityView(undefined, { + phase: "evidence-validation", + label: rememberLiveActivity(stageLabel ?? "正在处理…", null), + }); + 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, + stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool], + ); + currentActivity = nextActivityView(currentActivity, { + phase: rectificationToolActivityPhase(tool), + label: rememberLiveActivity(stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool], 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: "evidence-validation", + label: stageLabel + ? rememberLiveActivity(stageLabel, null) + : rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null), + completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), + }); + frames.touch(); + } + } + } + frames.settle(); + if (rejected) { + await reader.cancel().catch(() => undefined); + rejectTurn({ status: rejected.status }, rejected.payload); + return; + } + + 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) { + runOutcome = "succeeded"; + await loadCaseSnapshot((turns) => { + if (turns.length) { + // D4 (BUG-1046): a turn the server wrote alongside this one (the + // one carrying the next question) joins the transcript too. + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + } + }); + onMessagesChange?.([ + ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), + { role: "assistant", text: parsed.text }, + ]); + onCompleted?.(); + } else { + await loadCaseSnapshot((turns) => { + if (turns.length) { + setMessages((current) => mergeTurnQuestions(current, turns)); + } + }); + } + } catch (caught) { + frames.settle(); + const aborted = isAbortError(caught); + if (aborted) { + runOutcome = "stopped"; + setMessages((current) => current.map((message) => { + if (message.renderKey !== assistantRenderKey) return message; + return { + ...message, + text: raw, + activityTrace: completeActivityTrace(activityTrace), + state: "settled" as const, + completedReceipt, + failed: false, + stopped: true, + turnId: completedTurnId, + activity: undefined, + }; + })); + return; + } + setError("生时校正暂时不可用,请稍后再试。"); + setMessages((current) => withdrawTypedMark(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; + lastRunOutcome.current = readonly ? "readonly" : runOutcome; + setPending(false); + } +} diff --git a/frontend/src/lib/rectification-chat-view.ts b/frontend/src/lib/rectification-chat-view.ts new file mode 100644 index 00000000..f73da32c --- /dev/null +++ b/frontend/src/lib/rectification-chat-view.ts @@ -0,0 +1,291 @@ +/** + * What the rectification chat surface shows this render, derived from its + * state: which message carries the regenerate button, the live choice card, + * the range delivery card, the read-only range, the question gap + * (preparing / unavailable / persisted question / collect-waiting / + * delivered / verified idle), where the persisted question hangs, and the + * timeline bar. Moved out of RectificationAgenticChat unchanged + * (TASK-rectification-code-split-20260926). Pure: the component applies the + * returned `nextOfferLock` itself; no React hooks here. + */ +import { + canShowRectificationReadonlyRange, + canShowRectificationSelectionCards, +} from "@/lib/rectification-candidate-result"; +import { workingRectificationTime } from "@/lib/rectification-board-model"; +import { + RECTIFICATION_QUESTION_RETRY_LIMIT, + rectificationConversationState, + rectificationQuestionGapState, + interviewCollectWaiting, + interviewChoiceCardUnavailable, +} from "@/lib/rectification-surface-state"; +import { + buildRectificationTimeline, + type RectificationTimelineStage, +} from "@/lib/rectification-timeline-scale"; +import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy"; +import { isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; +import type { RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; +import { + interviewQuestionBlocksAdoptOffer, + nextSelectionCardLock, + questionIsAnswered, + resolveSelectionCardMessageKey, + turnQuestionFromCurrent, + type SelectionCardLock, +} from "@/lib/rectification-agentic/v9/turn-question"; +import { placePersistedQuestion } from "@/lib/rectification-snapshot-messages"; +import { isSettledAssistantMessage } from "@/lib/rectification-chat-messages"; +import type { CandidateResult, CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; +import type { RenderMessage } from "@/components/rectification-message-entry"; + +export type RectificationChatViewInput = Readonly<{ + messages: RenderMessage[]; + busy: boolean; + readonly: boolean; + regeneratingMessageKey: string | null; + shouldStartOpening: boolean; + openingRequested: boolean; + acceptingCandidateId: string | null; + selectionOfferLock: SelectionCardLock | null; + candidateResult: CandidateResult; + currentQuestion: CurrentQuestionModel | null; + questionSource: "focus" | "unavailable" | null; + choiceCard: ChoiceCardModel | null; + caseStatus: RectificationCaseStatus | null; + caseSnapshotLoaded: boolean; + searchWindow: readonly [string, string] | null; + caseStage: RectificationTimelineStage | null; + nextUserActionId: string | null; + interviewStopReason: string | null; + interviewSessionOutcome: string | null; + questionRetryAttempts: number; + savedTime: string | null; +}>; + +export function deriveRectificationChatView(input: RectificationChatViewInput) { + const { + messages, + busy, + readonly, + regeneratingMessageKey, + shouldStartOpening, + openingRequested, + acceptingCandidateId, + selectionOfferLock, + candidateResult, + currentQuestion, + questionSource, + choiceCard, + caseStatus, + caseSnapshotLoaded, + searchWindow, + caseStage, + nextUserActionId, + interviewStopReason, + interviewSessionOutcome, + questionRetryAttempts, + savedTime, + } = input; + 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 latestLiveQuestion = [...messages].reverse().find((message) => ( + message.role === "assistant" + && message.question + && !questionIsAnswered(message.question) + ))?.question ?? null; + const showLiveChoiceCard = Boolean( + latestLiveQuestion + && latestLiveQuestion.options?.length === 4 + && currentQuestion?.focus_id === latestLiveQuestion.focus_id + && choiceCard + && choiceCard.focus_id === latestLiveQuestion.focus_id + && (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify") + && !busy + && !readonly + && regeneratingMessageKey === null, + ); + const canOfferCards = canShowRectificationSelectionCards(candidateResult); + // Cheap arithmetic over at most a dozen candidate minutes; recomputed with + // the render rather than memoised, matching the rest of this component. + const timelineView = buildRectificationTimeline({ + searchWindow, + credibleRange: candidateResult?.credibleRange ?? null, + candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [], + inferenceMarks: candidateResult?.inferenceMarks ?? [], + stage: caseStage, + // The same call the board's title-row clock makes, on the same result, so + // the bar and `.rectification-board__clock` are one number by construction + // rather than by two code paths that happen to agree today. + workingTime: workingRectificationTime(candidateResult), + workingAdopted: Boolean(candidateResult?.selectedTime), + answeredProbeCount: candidateResult?.answeredProbeCount ?? null, + datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null, + }); + const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; + const liveSelectionCardKey = persistedOfferKey + ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); + // RectificationAgenticChat stores this lock during render (BUG-620); the + // comment on why sits next to that setSelectionOfferLock call. + const nextOfferLock = nextSelectionCardLock(selectionOfferLock, { + resultId: candidateResult?.resultId, + key: liveSelectionCardKey, + }); + const selectionCardMessageKey = resolveSelectionCardMessageKey({ + persistedOfferKey, + fallbackKey: latestSettledAssistant?.renderKey, + locked: nextOfferLock, + resultId: candidateResult?.resultId, + selectedTime: candidateResult?.selectedTime, + canOffer: canOfferCards, + }); + // Adopt already has in-card “正在采用…” / “已采用”; hiding the whole card + // for `busy` is what made the three columns vanish on 「更像这个」. + const keepSelectionCardsWhileBusy = Boolean( + acceptingCandidateId || candidateResult?.selectedTime, + ); + const liveInterviewBlockingAdopt = Boolean( + candidateResult + && !candidateResult.selectedTime + && messages.some((message) => ( + interviewQuestionBlocksAdoptOffer(message.question, false) + )), + ); + const showSelectionCards = Boolean( + candidateResult + && caseSnapshotLoaded + && (!busy || keepSelectionCardsWhileBusy) + && selectionCardMessageKey + && (canOfferCards || Boolean(candidateResult.selectedTime)) + ); + const showReadonlyRange = Boolean( + canShowRectificationReadonlyRange(candidateResult) + && latestSettledAssistant + && !showSelectionCards, + ); + const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" + ? currentQuestion.prompt + : null; + const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); + const deadChoice = interviewChoiceCardUnavailable({ + questionKind: currentQuestion?.kind, + hasChoiceCard: Boolean(choiceCard), + questionId: currentQuestion?.question_id, + }); + const liveQuestionOnMessages = Boolean( + latestSettledAssistant + && latestSettledAssistant.question + && currentQuestion + && latestSettledAssistant.question.focus_id === currentQuestion.focus_id + && !questionIsAnswered(latestSettledAssistant.question) + && !deadChoice, + ); + // 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 repair button) — never bare copy telling the + // reader to wait for the server. Collect-waiting is the training-gate gap: + // no next card, keep the case open, do not show 「没有拿到下一个问题」. + const collectWaiting = interviewCollectWaiting({ + stopReason: interviewStopReason, + sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, + questionMissing: currentQuestion === null, + offeringCards: showSelectionCards, + }); + // BUG-917: the same judgement the message entry makes, read off the latest + // settled assistant message: a tap question nobody can answer any more. + const unansweredDeadChoiceOnMessages = Boolean( + latestSettledAssistant?.question + && (latestSettledAssistant.question.kind === "choice" + || latestSettledAssistant.question.kind === "reverse_verify") + && !latestSettledAssistant.question.answer_option + && (latestSettledAssistant.question.status === "active" + || latestSettledAssistant.question.status === "superseded") + && !liveQuestionOnMessages, + ); + const questionGap = rectificationQuestionGapState({ + liveQuestionVisible: liveQuestionOnMessages, + unansweredDeadChoice: unansweredDeadChoiceOnMessages, + questionMissing: currentQuestion === null, + questionLoadFailed: questionSource === "unavailable" || deadChoice, + questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, + offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null, + nextUserActionId, + collectWaiting: collectWaiting && !deadChoice, + sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, + stopReason: interviewStopReason, + deadChoice, + questionId: currentQuestion?.question_id ?? null, + hasChoiceCard: Boolean(choiceCard), + busy, + readonly, + regenerating: regeneratingMessageKey !== null, + snapshotLoaded: caseSnapshotLoaded, + resumableCase, + retryAttempts: questionRetryAttempts, + retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, + }); + // BUG-1046 (D3): the persisted question is drawn once. With an assistant + // message to carry it, it hangs there (BUG-678's intent) and other copies of + // the same focus come off; the standalone block is only the fallback when + // nothing can carry it. The gap state itself is unchanged. + const persistedTurnQuestion = questionGap === "persisted_question" + ? turnQuestionFromCurrent(currentQuestion, choiceCard) + : null; + const placedQuestion = persistedTurnQuestion + ? placePersistedQuestion(messages, persistedTurnQuestion, isSettledAssistantMessage) + : null; + const placedByKey = placedQuestion && placedQuestion.messages !== messages + ? new Map(placedQuestion.messages.map((message) => [message.renderKey, message])) + : null; + const showStandaloneQuestion = questionGap === "persisted_question" + && (placedQuestion ? placedQuestion.standalone : true); + const verifiedIdleCopy = questionGap === "verified_idle" + ? postAdoptVerifyDoneCopy( + savedTime, + messages.some((message) => message.question?.kind === "reverse_verify"), + ) + : null; + const conversationState = rectificationConversationState({ + messageCount: messages.length, + busy, + readonly, + shouldStartOpening, + openingStarted: openingRequested, + }); + return { + latestRegeneratableKey, + latestSettledAssistant, + showLiveChoiceCard, + timelineView, + nextOfferLock, + selectionCardMessageKey, + liveInterviewBlockingAdopt, + showSelectionCards, + showReadonlyRange, + collectSpokenPrompt, + liveQuestionOnMessages, + questionGap, + placedByKey, + showStandaloneQuestion, + verifiedIdleCopy, + conversationState, + }; +} + +export type RectificationChatView = ReturnType; diff --git a/frontend/tests/account-dialog-inert-20260918.test.ts b/frontend/tests/account-dialog-inert-20260918.test.ts index 320fb73d..6548a38e 100644 --- a/frontend/tests/account-dialog-inert-20260918.test.ts +++ b/frontend/tests/account-dialog-inert-20260918.test.ts @@ -28,6 +28,7 @@ import { USER_ID, fakeAccounting, } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8"); @@ -131,7 +132,8 @@ test("GET /api/rectification/cases/[caseId] warns JSON on every non-200 branch ( }); test("a 503 snapshot miss shows status copy and 重新读取, not collect-waiting (BUG-969)", () => { - const chat = read("../src/components/rectification-agentic-chat.tsx"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.equal(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, "这一问还没读到。"); assert.equal(RECTIFICATION_SNAPSHOT_RETRY_LABEL, "重新读取"); assert.match(chat, /RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY/); diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 0778ceaf..76812437 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -28,6 +28,7 @@ import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent import { FORBIDDEN_RECORD_VERDICT_PHRASE } from "../src/lib/rectification-agentic/birth-time-provenance.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; /** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */ export const ACCIDENT_CASE_BEFORE_COPY = { @@ -156,7 +157,8 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8"); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8"); - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.match(agent, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/); assert.match(agent, /题干会作为同一条消息的下一段自动出现/); assert.match(agent, /开场轮:先 set-focus 写采集题的 spokenPrompt/); @@ -258,7 +260,8 @@ test("settled assistant body with a focus has no question-mark sentences", () => test("delivery narration is three sentences and the range card replaces minute cards", () => { const choice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const card = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8"); const accept = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8"); assert.doesNotMatch(choice, /withProspectiveWindows/); @@ -350,7 +353,8 @@ test("opening body lists domains without years and the stem no longer lists year assert.ok(MACHINE_VOICE_LEXICON.includes("有关联")); assert.ok(MACHINE_VOICE_LEXICON.includes("弱关联")); assert.ok(MACHINE_VOICE_LEXICON.includes("还在收窄")); - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.doesNotMatch(chat, /还在收窄/); }); diff --git a/frontend/tests/chat-composer-queue.test.ts b/frontend/tests/chat-composer-queue.test.ts index da69c507..4a258f7c 100644 --- a/frontend/tests/chat-composer-queue.test.ts +++ b/frontend/tests/chat-composer-queue.test.ts @@ -14,13 +14,13 @@ import { } from "../src/lib/queued-draft.ts"; import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts"; import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; +import { abortWiringOf } from "./rectification-chat-run-fixtures.ts"; const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"); -const rectification = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", -); +// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 +const rectification = rectificationChatSurface; const consultationRun = readFileSync( new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8", @@ -136,7 +136,7 @@ test("a queued card renders above the live textarea", () => { assert.match(html, /还想再问一句/); }); -test("rectification abort settles as stopped, not a failed alert", () => { +test("rectification abort settles as stopped, not a failed alert", async () => { assert.match(rectification, /stopped: true/); assert.match(rectification, /failed: false,\s*stopped: true/); assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/); @@ -149,18 +149,18 @@ test("rectification abort settles as stopped, not a failed alert", () => { assert.match(messageRow, /className="message-stopped-notice"/); assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。"); assert.match(rectification, /signal: abortController\.signal/); - const choiceFetch = rectification.slice( - rectification.indexOf("const submitStructuredChoice"), - rectification.indexOf("const acceptCandidate"), - ); - assert.match(choiceFetch, /runAbort\.current = abortController/); - assert.match(choiceFetch, /signal: abortController\.signal/); - const adoptFetch = rectification.slice( - rectification.indexOf("const acceptCandidate"), - rectification.indexOf("async function copyMessage"), - ); - assert.match(adoptFetch, /runAbort\.current = abortController/); - assert.match(adoptFetch, /signal: abortController\.signal/); + // 原值: 从 chat 源码切 submitStructuredChoice / acceptCandidate 两段,各匹配 `runAbort.current = abortController` 与 `signal: abortController.signal`。 + // 新值: 调用 runRectificationStructuredChoice / runRectificationCandidateAccept(假 fetch):请求带的 signal + // 就是 runAbort 里那个控制器的(停止按钮能掐断它),结束后 runAbort 清空。 + // 原因: TASK-rectification-code-split-20260926 两段函数体原样搬进参数函数;行为断言覆盖接线本身。 + const choiceWiring = await abortWiringOf("choice"); + assert.equal(choiceWiring.url, "/api/rectification/agent"); + assert.equal(choiceWiring.signalMatchesRunAbort, true); + assert.equal(choiceWiring.runAbortCleared, true); + const adoptWiring = await abortWiringOf("accept"); + assert.match(adoptWiring.url, /\/api\/rectification\/cases\/[^/]+\/candidates\/accept$/); + assert.equal(adoptWiring.signalMatchesRunAbort, true); + assert.equal(adoptWiring.runAbortCleared, true); assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast"); assert.doesNotMatch( rectification.slice(rectification.indexOf("{error &&"), rectification.indexOf("{readonly &&")), diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index 74b48d3b..f241c65e 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -15,6 +15,7 @@ import { turnTailOverflow, } from "../src/hooks/use-conversation-scroll-anchor.ts"; import { homeSurface } from "./home-surface.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8"); @@ -255,7 +256,8 @@ test("the turn spacer trails the actions row instead of sitting between text and assert.ok(entry.indexOf("\s* { - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? []; assert.ok(pins.length >= 3, `expected three pinLatestTurn calls, got ${pins.length}`); assert.doesNotMatch(chat, /conversation\.current\?\.scrollTo|element\.scrollTo/); diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index 312be9a4..b5eaebf5 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { chatMessageViews } from "../src/lib/chat-message-view.ts"; import { homeSurface as pageSource } from "./home-surface.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8"); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); @@ -190,10 +191,8 @@ test("docks the composer inside the chat panel instead of floating over content" test("ordinary consultation replies reuse the shared Agent action bar", () => { const actionsSource = readFileSync(new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8"); - const rectificationChat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const rectificationChat = rectificationChatSurface; for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) { assert.match(actionsSource, new RegExp(`aria-label="${label}"`)); } diff --git a/frontend/tests/composer-ime-contract.test.ts b/frontend/tests/composer-ime-contract.test.ts index e47da1e9..7b8d40ef 100644 --- a/frontend/tests/composer-ime-contract.test.ts +++ b/frontend/tests/composer-ime-contract.test.ts @@ -1,12 +1,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); -const rectification = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", -); +// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 +const rectification = rectificationChatSurface; const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8"); function onKeyDownBlock(source: string, marker: string) { diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index c6d663bc..0ae71e58 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { homeSurface as pageSource } from "./home-surface.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"); const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8"); // 原值:homeSurface 连同独立 /people 表单一起检查聊天根草稿。 @@ -57,10 +58,8 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio // subscription; a surface with its own draft (rectification) may pass `value` instead, and // must never write the main chat's store (BUG-477). assert.match(composerSource, /const storeDraft = useComposerDraft\(\);\n\s*const draft = value \?\? storeDraft;/); - const rectificationSource = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const rectificationSource = rectificationChatSurface; assert.match(rectificationSource, / { // Given: an ordinary question without a product entrypoint. @@ -184,7 +185,8 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case test("homepage mounts the Agentic surface without invoking retired rectification starters", () => { const page = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.match(page, / { test("rectify-first handoffs stay as Agent context", () => { const source = homeSurface; - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; // 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX // 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel @@ -467,7 +470,8 @@ test("the starter home hides technical chart parameters", () => { test("rectification 402 navigates to the membership page with the rectification source", () => { // Given: the Agentic rectification chat owns the /api/rectification/agent call. - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const membershipLib = readFileSync(new URL("../src/lib/membership.ts", import.meta.url), "utf8"); // 原值: 402 后 window.location.assign(membershipHref("rectification")) diff --git a/frontend/tests/rectification-activity-receipt.test.ts b/frontend/tests/rectification-activity-receipt.test.ts index c186ce24..83b31867 100644 --- a/frontend/tests/rectification-activity-receipt.test.ts +++ b/frontend/tests/rectification-activity-receipt.test.ts @@ -6,6 +6,14 @@ import { reduceRectificationActivityReceipt, receiptFromRectificationActivityState, } from "../src/lib/rectification-activity-receipt"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; +import type { RenderMessage } from "../src/components/rectification-message-entry.tsx"; +import { + chatRunRecorder, + ndjsonResponse, + runRectificationChatTurn, + withFakeFetch, +} from "./rectification-chat-run-fixtures.ts"; test("a successful retry clears the same tool's earlier failure", () => { let state = createRectificationActivityReceiptState(); @@ -89,30 +97,47 @@ test("a later failure removes methods contributed by the failed tool", () => { }); }); -const chatSource = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", -); +// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 +const chatSource = rectificationChatSurface; -test("answer deltas preserve a still-running server activity", () => { - const deltaBranch = chatSource.slice( - chatSource.indexOf('event.type === "answer.delta"'), - chatSource.indexOf('event.type === "run.failed"'), - ); +test("answer deltas preserve a still-running server activity", async () => { // Former locks: `state: raw.trim() ? "streaming" : "thinking"` and `text: raw,` inside the // delta branch. Those pinned one setMessages per network chunk (BUG-473); the same state // derivation now lives in the frame buffer's flush, which the delta branch feeds. - const frameFlush = chatSource.slice( - chatSource.indexOf("const frames = createStreamFrameBuffer"), - chatSource.indexOf("const abortController = new AbortController()"), - ); - assert.match(frameFlush, /state: text\.trim\(\) \? "streaming" : "thinking"/); - assert.match(frameFlush, /^\s*text,$/m); - assert.match(deltaBranch, /frames\.setAnswer\(raw\)/); - assert.match(deltaBranch, /event\.replace === true \? event\.text : raw \+ event\.text/); - assert.doesNotMatch(deltaBranch, /settled\.spoken/); - assert.match(deltaBranch, /正在组织回答/); - assert.doesNotMatch(deltaBranch, /activeActivity:\s*undefined/); + // 原值: 从 chat 源码切 answer.delta 分支(到 run.failed)与 frames 定义(到 new AbortController),匹配 + // flush 里 `state: text.trim() ? "streaming" : "thinking"`、`text,`,delta 分支里 `frames.setAnswer(raw)`、 + // `event.replace === true ? event.text : raw + event.text`、「正在组织回答」,且不含 settled.spoken / + // `activeActivity: undefined`。 + // 新值: 调用 runRectificationChatTurn(假 NDJSON 流): + // 1) 只有工具开始、没有正文时,flush 出的活行是 thinking、正文为空; + // 2) 两段 answer.delta(第二段 replace)后,flush 出的活行是 streaming、正文等于 replace 后的全文、 + // 活动标签是「正在组织回答…」且活动没被清掉;每段 delta 不各自 setMessages(一次提交)。 + // 原因: TASK-rectification-code-split-20260926 `send` 函数体原样搬进 rectification-chat-turn-run.ts; + // 对流读取结果断言比源码 token 更强。 + const liveRows = (history: RenderMessage[][]) => history + .map((messages) => messages.find((message) => message.renderKey === "v9-assistant-1")) + .filter((row): row is RenderMessage => Boolean(row)); + + const toolOnly = chatRunRecorder(); + await withFakeFetch(() => ndjsonResponse([ + { type: "tool.activity", tool: "rectification-read-case", status: "started" }, + ]), () => runRectificationChatTurn(toolOnly.turnDeps(), "message", "我 2019 年换了工作")); + const thinkingRow = liveRows(toolOnly.messageHistory).find((row) => row.state === "thinking" && row.activityTrace?.length); + assert.ok(thinkingRow, "the flush of a tool-only stream keeps a thinking row"); + assert.equal(thinkingRow.text, ""); + + const streamed = chatRunRecorder(); + await withFakeFetch(() => ndjsonResponse([ + { type: "tool.activity", tool: "rectification-read-case", status: "started" }, + { type: "answer.delta", text: "第一段" }, + { type: "answer.delta", text: "改写后的全文", replace: true }, + ]), () => runRectificationChatTurn(streamed.turnDeps(), "message", "我 2019 年换了工作")); + const streamingRows = liveRows(streamed.messageHistory).filter((row) => row.state === "streaming"); + assert.equal(streamingRows.length, 1, "deltas commit through the frame buffer, not one setMessages each"); + assert.equal(streamingRows[0].text, "改写后的全文"); + assert.match(streamingRows[0].activity?.label ?? "", /正在组织回答/); + assert.equal(streamingRows[0].activity?.phase, "answer-composition"); + assert.ok(streamed.liveLabels.includes("正在组织回答…")); }); test("failed server turns retain their receipt state without parsing activity from answer text", () => { diff --git a/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts index 8f174d37..2f18403f 100644 --- a/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts +++ b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts @@ -1,28 +1,51 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; +import { deriveRectificationChatView } from "../src/lib/rectification-chat-view.ts"; +import { chatViewInput, offerableCandidateResult, settledAssistant } from "./rectification-chat-run-fixtures.ts"; const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8"); -const chat = read("../src/components/rectification-agentic-chat.tsx"); +// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 +const chat = rectificationChatSurface; const delivery = read("../src/components/rectification-range-delivery.tsx"); const styles = read("../src/app/globals.css"); -const showBlock = chat.slice( - chat.indexOf("const keepSelectionCardsWhileBusy"), - chat.indexOf("const showReadonlyRange"), -); const messageLoop = chat.slice( chat.indexOf("{messages.map((message) => {"), chat.indexOf("{savedTime &&"), ); test("clicking 更像这个 does not unmount the range card for busy", () => { - assert.match(showBlock, /acceptingCandidateId \|\| candidateResult\?\.selectedTime/); - assert.match(showBlock, /!busy \|\| keepSelectionCardsWhileBusy/); - assert.doesNotMatch( - showBlock, - /&& !busy\s*&& selectionCardMessageKey/, - ); + // 原值: 从 chat 源码切 `const keepSelectionCardsWhileBusy` 到 `const showReadonlyRange`,匹配 + // `acceptingCandidateId || candidateResult?.selectedTime`、`!busy || keepSelectionCardsWhileBusy`, + // 且不含 `&& !busy && selectionCardMessageKey`。 + // 新值: 调用 deriveRectificationChatView:可交付结果挂在最新已结算回复上——空闲出卡;busy 且没在采用、 + // 没采用则收卡;busy 但正在采用(acceptingCandidateId)或已采用(带持久 offer)时卡仍在。 + // 原因: TASK-rectification-code-split-20260926 派生逻辑原样搬到 rectification-chat-view.ts(纯函数); + // 行为断言覆盖原三条 token 的意图,且更强。 + const result = offerableCandidateResult(); + const messages = [settledAssistant("offer-1")]; + const idle = deriveRectificationChatView(chatViewInput({ messages, candidateResult: result })); + assert.equal(idle.showSelectionCards, true); + assert.equal(idle.selectionCardMessageKey, "offer-1"); + const busyTurn = deriveRectificationChatView(chatViewInput({ messages, candidateResult: result, busy: true })); + assert.equal(busyTurn.showSelectionCards, false); + const adopting = deriveRectificationChatView(chatViewInput({ + messages, + candidateResult: result, + busy: true, + acceptingCandidateId: result.candidates[0].candidateId, + })); + assert.equal(adopting.showSelectionCards, true); + assert.equal(adopting.selectionCardMessageKey, "offer-1"); + const adopted = deriveRectificationChatView(chatViewInput({ + messages: [settledAssistant("offer-1", { candidateOffer: { resultId: result.resultId } })], + candidateResult: { ...result, selectedTime: "04:53" }, + busy: true, + })); + assert.equal(adopted.showSelectionCards, true); + assert.equal(adopted.selectionCardMessageKey, "offer-1"); }); test("after adopt the card stays on the locked offering message", () => { diff --git a/frontend/tests/rectification-adopt-flow-20260902.test.ts b/frontend/tests/rectification-adopt-flow-20260902.test.ts index 36a4cd35..0eaa5305 100644 --- a/frontend/tests/rectification-adopt-flow-20260902.test.ts +++ b/frontend/tests/rectification-adopt-flow-20260902.test.ts @@ -44,6 +44,7 @@ import { rectificationLiveProgressLabel, } from "../src/lib/rectification-activity-labels.ts"; import { OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const preAdopt = JSON.parse(readFileSync( new URL("./fixtures/rectification-case-bf7a8de6-pre-adopt.json", import.meta.url), @@ -397,10 +398,8 @@ test("skipped collect questions stay attached to their asked turn", () => { }); test("chat owns adopt cards on the offering message and drops the list-end handoff", () => { - const chat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.match(chat, /canShowRectificationSelectionCards/); assert.match(chat, /canShowRectificationReadonlyRange/); assert.match(chat, /candidateOffer/); diff --git a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts index 64c0728f..a0d7bb23 100644 --- a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts +++ b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts @@ -25,6 +25,7 @@ import { fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, @@ -280,10 +281,8 @@ test("adopt_representative nonterminal exit stays satisfied without a new questi }); test("adopted time is shown on the card row, not a composer status bar", () => { - const chat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); // 原值: 「改选」是状态条按钮,滚动到出卡消息 // 新值: 点其他候选行即改选;无 rectification-adopt-status @@ -364,10 +363,8 @@ test("consult handoff button and startConsultationAfterRectification are gone", assert.equal(source.includes(token), false, `${file} still contains ${token}`); } } - const chat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; // 原值: 状态条「之后新建对话即按此时间排盘」 // 新值: 无状态条;采用后旁白走 postAdoptVerifyDoneCopy;开场仍可预告结束后新建对话 // 原因: BUG-595 决策 3 diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index d2823ecd..9d788ded 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -2,16 +2,28 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { homeSurface as page } from "./home-surface.ts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; +import { + abortWiringOf, + chatViewInput, + fixtureChoiceCard, + offerableCandidateResult, + RUN_FOCUS_ID, + settledAssistant, +} from "./rectification-chat-run-fixtures.ts"; +import { deriveRectificationChatView } from "../src/lib/rectification-chat-view.ts"; +import { completedReceiptFromPersisted } from "../src/lib/rectification-chat-messages.ts"; +import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx"; const component = readFileSync( new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8", ); const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8"); -const chat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", -); +// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 +const chat = rectificationChatSurface; const messageEntry = readFileSync( new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8", @@ -362,15 +374,29 @@ test("Agentic rectification follows the conversation tail only while the reader }); test("rectification keeps receipts for the varga sentence and shows live tool progress", () => { - const activityHelper = chat.slice( - chat.indexOf("function completedReceiptFromPersisted"), - chat.indexOf("export function RectificationAgenticChat"), - ); - assert.match(activityHelper, /receipt\.tools/); - assert.doesNotMatch(activityHelper, /receipt\.phases/); - assert.match(activityHelper, /filter\(isPublicRectificationTool\)/); - assert.match(activityHelper, /receipt\.methods/); - assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/); + // 原值: 从 chat 源码切 `function completedReceiptFromPersisted` 到 `export function RectificationAgenticChat`, + // 匹配 receipt.tools / receipt.methods / filter(isPublicRectificationTool) / filter(isPublicRectificationMethod),且不含 receipt.phases。 + // 新值: 直接调用 completedReceiptFromPersisted:旧形状回执只留公开工具(去重)与公开方法,phases 不进步骤; + // tool_activities 形状只收 completed / failed 的公开工具与公开方法。 + // 原因: TASK-rectification-code-split-20260926 把该函数原样搬到 rectification-chat-messages.ts;对返回值断言强于源码 token。 + const legacyReceipt = completedReceiptFromPersisted({ + status: "completed", + phases: ["case.loaded", "run.completed"], + tools: ["rectification-read-case", "internal-debug-tool", "rectification-read-case"], + methods: ["d1-rashi", "not-a-public-method"], + }); + assert.deepEqual(legacyReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] }); + const activityReceipt = completedReceiptFromPersisted({ + status: "completed", + phases: ["case.loaded"], + tools: ["rectification-set-focus"], + tool_activities: [ + { tool: "rectification-read-case", status: "completed", methods: ["d1-rashi", "not-a-public-method"] }, + { tool: "internal-debug-tool", status: "completed" }, + { tool: "rectification-set-focus", status: "started" }, + ], + }); + assert.deepEqual(activityReceipt, { steps: ["rectification-read-case"], methods: ["d1-rashi"] }); // 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。 // 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。 // 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。 @@ -643,19 +669,25 @@ test("rectification composer can stop a live agent run", () => { assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/); }); -test("aborting a live run marks the bubble stopped without an alert", () => { +test("aborting a live run marks the bubble stopped without an alert", async () => { assert.match(chat, /failed: false,\s*stopped: true/); // 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。 // 新值: 同一表达式在行组件内。 // 原因: BUG-725。 assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/); assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/); - const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate")); - assert.match(choiceFetch, /runAbort\.current = abortController/); - assert.match(choiceFetch, /signal: abortController\.signal/); - const adoptFetch = chat.slice(chat.indexOf("const acceptCandidate"), chat.indexOf("async function copyMessage")); - assert.match(adoptFetch, /runAbort\.current = abortController/); - assert.match(adoptFetch, /signal: abortController\.signal/); + // 原值: 从 chat 源码切 submitStructuredChoice / acceptCandidate 两段,各匹配 `runAbort.current = abortController` 与 `signal: abortController.signal`。 + // 新值: 调用 runRectificationStructuredChoice / runRectificationCandidateAccept(假 fetch):请求带的 signal + // 就是 runAbort 里那个控制器的(停止按钮能掐断它),结束后 runAbort 清空。 + // 原因: TASK-rectification-code-split-20260926 两段函数体原样搬进参数函数;行为断言覆盖接线本身。 + const choiceWiring = await abortWiringOf("choice"); + assert.equal(choiceWiring.url, "/api/rectification/agent"); + assert.equal(choiceWiring.signalMatchesRunAbort, true); + assert.equal(choiceWiring.runAbortCleared, true); + const adoptWiring = await abortWiringOf("accept"); + assert.match(adoptWiring.url, /\/api\/rectification\/cases\/[^/]+\/candidates\/accept$/); + assert.equal(adoptWiring.signalMatchesRunAbort, true); + assert.equal(adoptWiring.runAbortCleared, true); }); test("conversation and house board reuse the quiet overlay scrollbar", () => { @@ -696,14 +728,22 @@ test("time-selection cards use server adoption state and stay mutually exclusive assert.match(chat, /keepSelectionCardsWhileBusy/); assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/); assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/); - assert.match( - chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")), - /!busy \|\| keepSelectionCardsWhileBusy/, - ); - assert.doesNotMatch( - chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")), - /offeredSelectionOnce/, + // 原值: 从 chat 源码切 `const showSelectionCards` 到 `const collectSpokenPrompt`:含 + // `!busy || keepSelectionCardsWhileBusy`,不含 `offeredSelectionOnce`。 + // 新值: 调用 deriveRectificationChatView:busy 收卡、busy 但正在采用留卡;同一输入先后派生两次结果相同, + // 出过一次卡不会留下隐藏记忆。 + // 原因: TASK-rectification-code-split-20260926 派生逻辑原样搬进纯函数 rectification-chat-view.ts。 + const offerable = offerableCandidateResult(); + const offerMessages = [settledAssistant("offer-1")]; + const offerIdle = chatViewInput({ messages: offerMessages, candidateResult: offerable }); + assert.equal(deriveRectificationChatView(offerIdle).showSelectionCards, true); + assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false); + assert.equal( + deriveRectificationChatView({ ...offerIdle, busy: true, acceptingCandidateId: offerable.candidates[0].candidateId }).showSelectionCards, + true, ); + // 出过卡、采用中之后再来一轮 busy:仍收卡,没有 offeredSelectionOnce 式的记忆。 + assert.equal(deriveRectificationChatView({ ...offerIdle, busy: true }).showSelectionCards, false); assert.match(chat, /!candidateResult\?\.selectedTime \? latestSettledAssistant\?\.renderKey/); // 原值: showSelectionCards && message.renderKey === selectionCardMessageKey // 新值: 还要有 candidateResult 才渲染行选卡 @@ -716,10 +756,32 @@ test("time-selection cards use server adoption state and stay mutually exclusive assert.match(messageEntry, /variant="embedded"/); assert.match(messageEntry, /choiceNonce/); assert.match(messageEntry, /question\.answer_option/); - assert.match(messageLoop, / = Readonly<{ + get: () => T; + set: (next: SetStateAction) => void; +}>; + +export function recordedState(initial: T): RecordedState { + let value = initial; + return { + get: () => value, + set: (next) => { + value = typeof next === "function" ? (next as (current: T) => T)(value) : next; + }, + }; +} + +export type FetchCall = Readonly<{ url: string; init: RequestInit | undefined; body: Record | null }>; + +/** + * Swap `fetch` (and the `window` frame scheduler the stream buffer needs) for + * the duration of `run`. `respond` sees each call in order. + */ +export async function withFakeFetch( + respond: (call: FetchCall, index: number) => Response | Promise, + run: (calls: FetchCall[]) => Promise, +): Promise { + const calls: FetchCall[] = []; + const originalFetch = globalThis.fetch; + const globals = globalThis as { window?: unknown }; + const originalWindow = globals.window; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record : null; + const call = { url: String(input), init, body }; + calls.push(call); + return respond(call, calls.length - 1); + }) as typeof fetch; + globals.window = { + requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: ReturnType) => clearTimeout(handle), + setTimeout, + clearTimeout, + }; + try { + return await run(calls); + } finally { + globalThis.fetch = originalFetch; + globals.window = originalWindow; + } +} + +export function ndjsonResponse(events: readonly Record[], init: ResponseInit = {}): Response { + return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, { + status: 200, + headers: { "content-type": "application/x-ndjson; charset=utf-8" }, + ...init, + }); +} + +export function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** Everything the three run functions write, recorded for assertions. */ +export function chatRunRecorder(initialMessages: RenderMessage[] = []) { + const transcript = recordedState(initialMessages); + /** Every transcript the run committed, in order (each setMessages call). */ + const messageHistory: RenderMessage[][] = []; + const messages: RecordedState = { + get: transcript.get, + set: (next) => { + transcript.set(next); + messageHistory.push(transcript.get()); + }, + }; + const error = recordedState(""); + const choiceNonce = recordedState(0); + const nextUserActionId = recordedState(null); + const acceptingCandidateId = recordedState(null); + const candidateResult = recordedState(null); + const savedTime = recordedState(null); + const savedStatus = recordedState<"accepted" | "confirmed" | null>(null); + const pending: boolean[] = []; + const liveLabels: string[] = []; + const snapshotLoads: Array<((turns: readonly unknown[]) => void) | undefined> = []; + const pins: number[] = []; + const sends: Array> = []; + const completed: number[] = []; + const profileIncomplete: number[] = []; + const billing: Array<{ source?: string } | undefined> = []; + const messageChanges: unknown[] = []; + const saved: Array<[string, string]> = []; + const refs = { + keyCounter: { current: 0 }, + currentQuestionRef: { current: null as CurrentQuestionModel | null }, + runAbort: { current: null as AbortController | null }, + lastRunOutcome: { current: null as RectificationRunOutcome }, + }; + const shared = { + caseId: RUN_CASE_ID, + sessionId: RUN_SESSION_ID, + busy: false, + readonly: false, + ...refs, + conversationAnchor: { pinLatestTurn: () => { pins.push(Date.now()); } }, + setError: error.set, + setMessages: messages.set, + setPending: (value: boolean) => { pending.push(value); }, + beginLiveRun: (label: string) => { liveLabels.push(label); }, + loadCaseSnapshot: async (merge?: (turns: readonly unknown[]) => void) => { + snapshotLoads.push(merge); + merge?.([]); + return undefined; + }, + send: async (action: RectificationTurnAction, messageText: string, continuation?: RectificationTurnContinuation) => { + sends.push({ action, messageText, continuation }); + }, + onCompleted: () => { completed.push(1); }, + onProfileIncomplete: () => { profileIncomplete.push(1); }, + onMessagesChange: (next: unknown) => { messageChanges.push(next); }, + }; + + function turnDeps(overrides: Partial = {}): RectificationChatTurnDeps { + return { + ...shared, + selectedModelId: "model-a", + conversationReadonly: false, + rememberLiveActivity: (label: string) => { liveLabels.push(label); return label; }, + onOpenBilling: (options?: { source?: string }) => { billing.push(options); }, + ...overrides, + }; + } + + function choiceDeps( + choiceCard: RectificationChoiceCard | null, + overrides: Partial = {}, + ): RectificationStructuredChoiceDeps { + return { + ...shared, + choiceCard, + currentQuestion: null, + actionIdForChoice: () => globalThis.crypto.randomUUID(), + setChoiceNonce: choiceNonce.set, + setNextUserActionId: nextUserActionId.set, + ...overrides, + }; + } + + function acceptDeps(overrides: Partial = {}): RectificationCandidateAcceptDeps { + return { + ...shared, + candidateResult: candidateResult.get(), + acceptingCandidateId: acceptingCandidateId.get(), + setAcceptingCandidateId: acceptingCandidateId.set, + setCandidateResult: candidateResult.set, + setSavedTime: savedTime.set, + setSavedStatus: savedStatus.set, + onSaved: (time: string, status: string) => { saved.push([time, status]); }, + ...overrides, + }; + } + + return { + messages, + messageHistory, + error, + choiceNonce, + nextUserActionId, + acceptingCandidateId, + candidateResult, + savedTime, + savedStatus, + pending, + liveLabels, + snapshotLoads, + pins, + sends, + completed, + profileIncomplete, + billing, + messageChanges, + saved, + refs, + turnDeps, + choiceDeps, + acceptDeps, + }; +} + +export function fixtureChoiceCard(overrides: Partial = {}): RectificationChoiceCard { + return { + question_id: "distinguish:1", + method_id: "candidate_discriminator", + prompt: "2023 年前后,有没有换过工作或职责明显变化?", + why: "", + varga: null, + choice_mode: "A/B/C/D", + options: [ + { key: "A", label: "明确发生且时间吻合" }, + { key: "B", label: "发生过但程度较弱" }, + { key: "C", label: "没有这回事" }, + { key: "D", label: "这段记不清楚" }, + ], + stop_label: "先这样,先看当前范围", + stop_message: "", + scoring: true, + probe_id: "probe:1", + case_revision: 3, + focus_id: RUN_FOCUS_ID, + ...overrides, + } as RectificationChoiceCard; +} + +export function fixtureCandidateResult(): NonNullable { + return { + resultId: "result-1", + candidates: [ + { candidateId: "cand-1", time: "05:07" }, + { candidateId: "cand-2", time: "05:15" }, + ], + selectedTime: null, + } as unknown as NonNullable; +} + +/** + * Where the stop button's AbortController goes during a tapped choice or an + * adoption: is the controller in `runAbort` the one whose signal the request + * carries, and is it cleared once the run ends? + */ +export async function abortWiringOf(kind: "choice" | "accept") { + const recorder = chatRunRecorder(); + let signalMatchesRunAbort = false; + let url = ""; + await withFakeFetch((call) => { + url = call.url; + signalMatchesRunAbort = Boolean( + recorder.refs.runAbort.current + && call.init?.signal === recorder.refs.runAbort.current.signal, + ); + return jsonResponse({ ok: false }, 500); + }, async () => { + if (kind === "choice") { + await runRectificationStructuredChoice(recorder.choiceDeps(fixtureChoiceCard()), "answer_choice", "A"); + } else { + recorder.candidateResult.set(fixtureCandidateResult()); + await runRectificationCandidateAccept(recorder.acceptDeps(), "cand-1"); + } + }); + return { + url, + signalMatchesRunAbort, + runAbortCleared: recorder.refs.runAbort.current === null, + }; +} + +/** + * A result the delivery card may offer: `completed_with_range` with selection + * allowed, parsed by the real parser (same snapshot shape as + * rectification-delivery-vs-collect-20260914.test.ts). + */ +export function offerableCandidateResult(overrides: Record = {}): NonNullable { + const result = parseRectificationCandidateResult({ + resultId: "11111111-1111-4111-8111-111111111111", + candidates: [ + { candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 }, + { candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 }, + ], + overallConfidence: "medium", + selectionAllowed: true, + canAdopt: false, + confirmationAllowed: false, + representativeTime: "04:53", + selectedTime: null, + selectionKind: null, + credibleRange: ["04:48", "05:07"], + session_outcome: "completed_with_range", + decisionReceipt: { + display_allowed: true, + selection_allowed: true, + acceptance_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + accept_allowed: true, + confirm_allowed: false, + }, + ...overrides, + }); + if (!result) throw new Error("offerableCandidateResult: parser rejected the fixture"); + return result; +} + +export function settledAssistant(renderKey: string, extra: Partial = {}): RenderMessage { + return { role: "assistant", text: `回复 ${renderKey}`, renderKey, state: "settled", ...extra }; +} + +/** Input for deriveRectificationChatView with a quiet, loaded, idle surface. */ +export function chatViewInput(overrides: Partial = {}): RectificationChatViewInput { + return { + messages: [], + busy: false, + readonly: false, + regeneratingMessageKey: null, + shouldStartOpening: false, + openingRequested: false, + acceptingCandidateId: null, + selectionOfferLock: null, + candidateResult: null, + currentQuestion: null, + questionSource: null, + choiceCard: null, + caseStatus: null, + caseSnapshotLoaded: true, + searchWindow: null, + caseStage: null, + nextUserActionId: null, + interviewStopReason: null, + interviewSessionOutcome: null, + questionRetryAttempts: 0, + savedTime: null, + ...overrides, + }; +} + +export { runRectificationChatTurn, runRectificationStructuredChoice, runRectificationCandidateAccept }; diff --git a/frontend/tests/rectification-chat-surface.ts b/frontend/tests/rectification-chat-surface.ts new file mode 100644 index 00000000..40c10bf1 --- /dev/null +++ b/frontend/tests/rectification-chat-surface.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; + +/** + * The rectification chat surface as source text: the container component and + * the hooks, parameter functions and blocks it was split into + * (TASK-rectification-code-split-20260926). Same idea as `home-surface.ts`: + * a whole-source contract that held on `rectification-agentic-chat.tsx` + * before the split holds on this concatenation after it, because the code is + * the same code in more files. Slices between two anchors do not survive the + * split; those tests call the extracted functions instead. + */ +export const rectificationChatSurfaceFiles = [ + "../src/components/rectification-agentic-chat.tsx", + "../src/components/rectification-question-gap-notices.tsx", + "../src/hooks/use-rectification-case-snapshot.ts", + "../src/hooks/use-rectification-board-layout.ts", + "../src/hooks/use-rectification-live-activity.ts", + "../src/lib/rectification-chat-messages.ts", + "../src/lib/rectification-chat-snapshot.ts", + "../src/lib/rectification-chat-turn-run.ts", + "../src/lib/rectification-chat-choice-run.ts", + "../src/lib/rectification-chat-accept-run.ts", + "../src/lib/rectification-chat-message-actions.ts", + "../src/lib/rectification-chat-question-repair.ts", + "../src/lib/rectification-chat-view.ts", +] as const; + +export function readRectificationChatFile(relativePath: (typeof rectificationChatSurfaceFiles)[number]): string { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +export const rectificationChatSurface = rectificationChatSurfaceFiles + .map((relativePath) => readRectificationChatFile(relativePath)) + .join("\n"); diff --git a/frontend/tests/rectification-dead-d9-choice-20260916.test.ts b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts index 567122ab..a6c80c0c 100644 --- a/frontend/tests/rectification-dead-d9-choice-20260916.test.ts +++ b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts @@ -30,6 +30,7 @@ import { } from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts"; import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; import { FOCUS_ID } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; const OWNED_KEY = "d9_relationship:relationship_style"; const QUESTION_ID = "d9_relationship:relationship_style:score"; @@ -449,10 +450,8 @@ test("a dead tap card never shares the screen with the collect-wait placeholder rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }), "unavailable", ); - const chat = readFileSync( - new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), - "utf8", - ); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/); assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/); const entry = readFileSync( diff --git a/frontend/tests/rectification-delivery-ui-simplify-20260908.test.ts b/frontend/tests/rectification-delivery-ui-simplify-20260908.test.ts index 2c4a915c..b4c94e6a 100644 --- a/frontend/tests/rectification-delivery-ui-simplify-20260908.test.ts +++ b/frontend/tests/rectification-delivery-ui-simplify-20260908.test.ts @@ -15,6 +15,7 @@ import { resetDeliveryTurnGuardForTests, } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts"; import { RectificationVerificationReport } from "../src/components/rectification-verification-report.tsx"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; test("delivery body is three sentences and omits the eight-method report", () => { const text = deliveryTurnNarration({ @@ -101,7 +102,8 @@ test("agent-run trims evidence after persistNextInterviewIfIdle so delivery can }); test("composer wrap no longer hosts 先这样 or the adopt status bar", () => { - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\"")); assert.doesNotMatch(wrap, /CHOICE_STOP_LABEL/); assert.doesNotMatch(wrap, /rectification-collect-stop/); diff --git a/frontend/tests/rectification-engine-version-cross-midnight.test.ts b/frontend/tests/rectification-engine-version-cross-midnight.test.ts index 2d42627e..7b7805d0 100644 --- a/frontend/tests/rectification-engine-version-cross-midnight.test.ts +++ b/frontend/tests/rectification-engine-version-cross-midnight.test.ts @@ -22,6 +22,7 @@ import { CASE_ID, SESSION_ID, TURN_ID, USER_ID, FOCUS_ID, computeFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; // Real native-engine response, using explicitly fictional input, not a hand-built contract. const golden = JSON.parse(readFileSync(new URL( @@ -234,7 +235,8 @@ test("stale history exposes explicit recompare and real tools replace old proven assert.equal(refreshed.case.result_identity.read_only, false); assert.equal(refreshed.case.result_identity.algorithm_version, CURRENT); assert.equal(refreshed.case.result_identity.policy_version, golden.versions.decision_policy_version); - const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const component = rectificationChatSurface; assert.match(component, /resultNotice && canRecompare && !historyReadonly/); assert.match(component, /recompareHistoricalResult\(\{ canRecompare, historyReadonly, busy, send \}\)/); assert.match(component, /if \(\(action === "message" && !trimmed\) \|\| conversationReadonly\) return/); diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 40971ef9..d601d80b 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -36,6 +36,10 @@ import { fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { rectificationChatSurface } from "./rectification-chat-surface.ts"; +import { RectificationReadonlyRange } from "../src/components/rectification-question-gap-notices.tsx"; const EXISTENCE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, @@ -632,12 +636,20 @@ test("collect spoken stop stays on choice cards; range line is status only", () // 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop // 新值: CHOICE_STOP_LABEL 只在选择题卡;composer-wrap 不再挂先这样 // 原因: BUG-595 决策 2 - const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 + const chat = rectificationChatSurface; const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); - const rangeFn = chat.slice( - chat.indexOf("function RectificationReadonlyRange"), - chat.indexOf("type RectificationAgenticChatProps"), - ); + // 原值: 从 chat 源码切 `function RectificationReadonlyRange` 到 `type RectificationAgenticChatProps`, + // 断言含 role="status"、不含 onStop / [^<]*<\/p>$/); const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\"")); assert.doesNotMatch(wrap, /rectification-collect-stop/); // 原值: 容器源码含 CHOICE_STOP_LABEL。 @@ -649,7 +661,7 @@ test("collect spoken stop stays on choice cards; range line is status only", () assert.match(chat, /function submitStop/); assert.match(chat, /RectificationReadonlyRange/); assert.match(rangeFn, /role="status"/); - assert.doesNotMatch(rangeFn, /onStop/); + assert.doesNotMatch(rangeFn, /onStop|先这样/); assert.doesNotMatch(rangeFn, /