"use client"; import { ArrowUp, Square } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { parseAgentReply } from "@/lib/agent-reply"; import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; import { completeActivityTrace, completeActivityTraceStep, emptyActivityTrace, freezeLiveThink, startActivityTraceStep, type AgentActivityTraceItem, } from "@/lib/agent-activity-trace"; import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS, RECTIFICATION_TOOL_DONE_LABELS, RECTIFICATION_TOOL_PROGRESS_LABELS, activityTraceFromReceipt, rectificationCompletedTrail, rectificationToolActivityPhase, } from "@/lib/rectification-activity-labels"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, type CompletedActivityReceiptView, } from "@/lib/rectification-activity-receipt"; import { isRecommendedRectificationCandidate, natalRecastMeaning, parseRectificationCandidateResult, workingRectificationHouseTable, type RectificationCandidateResult, } from "@/lib/rectification-candidate-result"; import { diffRectificationBoard, RECTIFICATION_BOARD_SPLIT_MIN_PX, } from "@/lib/rectification-board-model"; import { membershipHref } from "@/lib/membership"; import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence"; import { isPublicRectificationActivity, isPublicRectificationMethod, isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky-scroll"; import { CHOICE_ACTION, STOP_ACTION, shouldContinueAfterStructuredChoice, stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer"; import { CHOICE_STOP_MESSAGE, choiceCardUserMessage, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel, } from "@/lib/rectification-agentic/v9/choice-card"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; import { ChatMessageActions, toggleChatMessageFeedback, } from "./chat-message-actions"; import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; type PersistedTurn = Readonly<{ id: string; role: "user" | "assistant"; text: string | null; status: string; receipt?: Readonly<{ status: string; phases: readonly string[]; tool_activities?: readonly Readonly<{ tool: string; status: string; methods?: readonly string[]; }>[]; tools: readonly string[]; methods?: readonly string[]; skill_name?: string; skill_version?: string; }> | null; }>; type CandidateResult = RectificationCandidateResult | null; function RectificationCandidateCards({ result, acceptingCandidateId, readonly, onAccept, }: Readonly<{ result: RectificationCandidateResult; acceptingCandidateId: string | null; readonly: boolean; onAccept: (candidateId: string) => void; }>) { return (
当前可能的出生时间 相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。
{result.candidates.map((candidate) => { const selected = result.selectedTime === candidate.time; const recommended = isRecommendedRectificationCandidate(result, candidate); return ( ); })}
); } type RectificationAgenticChatProps = Readonly<{ caseId: string; sessionId: string; readonly: boolean; shouldStartOpening: boolean; initialTurns: readonly PersistedTurn[]; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; onMessagesChange?: (messages: ChatMessage[]) => void; onCompleted?: () => void; onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onStartConsultation?: () => void; onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; headerSlot: HTMLElement | null; }>; type RenderMessage = ChatMessageView & { renderKey: string; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; activityTrace?: readonly AgentActivityTraceItem[]; }; function turnOfferedSelection(message: RenderMessage): boolean { return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates")); } function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { if (!receipt) return { steps: [], methods: [] }; if (Array.isArray(receipt.tool_activities)) { let state = createRectificationActivityReceiptState(); for (const activity of receipt.tool_activities) { if (!isPublicRectificationTool(activity.tool) || (activity.status !== "completed" && activity.status !== "failed")) continue; state = reduceRectificationActivityReceipt(state, { tool: activity.tool, status: activity.status, methods: activity.status === "completed" && Array.isArray(activity.methods) ? activity.methods.filter(isPublicRectificationMethod) : [], }); } return receiptFromRectificationActivityState(state); } return { steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))], methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))], }; } export function toggleRectificationFeedback( current: "up" | "down" | undefined, requested: "up" | "down", ): "up" | "down" | undefined { return toggleChatMessageFeedback(current, requested); } function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean { return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool); } function persistedTurnFailed(turn: PersistedTurn): boolean { return turn.status === "failed" || turn.status === "retryable" || turn.receipt?.status === "failed" || turn.receipt?.status === "degraded" || turn.receipt?.status === "blocked"; } function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] { return initialTurns.flatMap((turn, index): RenderMessage[] => { const key = `persisted-${turn.id}-${index}`; if (turn.role === "assistant") { const failed = persistedTurnFailed(turn); const raw = failed ? "" : turn.text ?? ""; if (failed && !raw) return []; if (isIncompleteRunBanner(raw)) return []; const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw }; const completedReceipt = completedReceiptFromPersisted(turn.receipt); return [{ role: "assistant", text: split.spoken, renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt, activityTrace: activityTraceFromReceipt(completedReceipt), failed, turnId: turn.id, }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; return [{ role: "user", text: turn.text ?? "", renderKey: key, state: "settled", }]; }); } export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, sessionId, readonly, shouldStartOpening, initialTurns, models, selectedModelId, onSelectModel, onMessagesChange, onCompleted, onPendingChange, onProfileIncomplete, onSaved, onStartConsultation, onOpeningConsumed, pendingConsultationQuestion, onRestart, headerSlot, } = props; const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [savedTime, setSavedTime] = useState(null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); const [candidateResult, setCandidateResult] = useState(null); const [choiceCard, setChoiceCard] = useState(null); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const [choiceNonce, setChoiceNonce] = useState(0); const choiceContinuationPending = useRef(false); const conversation = useRef(null); const workspace = useRef(null); const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); const previousBoardResult = useRef(null); const runAbort = useRef(null); const followTailRef = useRef(true); const scrollFrameRef = useRef(null); const choiceActionIds = useRef(new Map()); const choiceCardsOpen = useRef(false); const [showJumpToLatest, setShowJumpToLatest] = useState(false); const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); const boardId = useId(); const boardTitleId = useId(); useLayoutEffect(() => { const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`); const update = () => { const nextCompact = query.matches; setCompactBoard(nextCompact); if (!nextCompact) setBoardOpen(false); }; update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); useEffect(() => { setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult)); previousBoardResult.current = candidateResult; }, [candidateResult]); const closeBoard = useCallback(() => setBoardOpen(false), []); const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []); const stopRun = useCallback(() => { runAbort.current?.abort(); }, []); const setPending = useCallback((value: boolean) => { setBusy(value); onPendingChange?.(value); }, [onPendingChange]); const updateFollowState = useCallback(() => { const viewport = conversation.current; if (!viewport) return; const nearBottom = isNearBottom(viewport.scrollHeight, viewport.scrollTop, viewport.clientHeight); followTailRef.current = nearBottom; setShowJumpToLatest(shouldShowJumpToLatest( viewport.scrollHeight, viewport.scrollTop, viewport.clientHeight, choiceCardsOpen.current, )); }, []); const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => { const viewport = conversation.current; if (!viewport) return; followTailRef.current = true; setShowJumpToLatest(false); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; viewport.scrollTo({ top: viewport.scrollHeight, behavior: reduceMotion ? "auto" : behavior, }); }, []); const followLatestContent = useCallback(() => { if (!followTailRef.current) return; if (scrollFrameRef.current !== null) { cancelAnimationFrame(scrollFrameRef.current); } scrollFrameRef.current = requestAnimationFrame(() => { const container = conversation.current; if (!container || !followTailRef.current) return; container.scrollTop = container.scrollHeight; }); }, []); useLayoutEffect(() => { followLatestContent(); return () => { if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }; }, [busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: { latest_result?: unknown; choice_card?: unknown; case?: { accepted_time?: unknown; confirmed_time?: unknown }; } | null) => { if (!payload) return; const nextCandidate = parseRectificationCandidateResult(payload.latest_result); const nextChoice = parseRectificationChoiceCard(payload.choice_card); const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; setCandidateResult(nextCandidate); setChoiceCard(nextChoice); if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); } else if (acceptedTime) { setSavedTime(acceptedTime); setSavedStatus("accepted"); } }, []); const loadCaseSnapshot = useCallback(async () => { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store" }, ); if (!response.ok) return; applyCaseSnapshot(await response.json().catch(() => null)); } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. } }, [applyCaseSnapshot, caseId, sessionId]); useEffect(() => { const controller = new AbortController(); void fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store", signal: controller.signal }, ) .then((response) => (response.ok ? response.json() : null)) .then((payload) => { if (!controller.signal.aborted) applyCaseSnapshot(payload); }) .catch(() => { // Snapshot refresh is best-effort; the durable Case remains on the server. }); return () => controller.abort(); }, [applyCaseSnapshot, caseId, sessionId]); const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => { const trimmed = action === "message" ? messageText.trim() : ""; if ((action === "message" && !trimmed) || busy || readonly) return; setError(""); setPending(true); keyCounter.current += 1; const requestId = globalThis.crypto.randomUUID(); const turnKey = keyCounter.current; const userRenderKey = `v9-user-${turnKey}`; const assistantRenderKey = `v9-assistant-${turnKey}`; setMessages((current) => [ ...current, ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: "正在处理…", startedAt: Date.now(), } }, ]); setDraft(""); let raw = ""; let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; const abortController = new AbortController(); runAbort.current = abortController; try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, signal: abortController.signal, body: JSON.stringify({ caseId, sessionId, requestId, action, modelId: selectedModelId, ...(action === "message" ? { message: trimmed } : {}), origin: action === "read_only" ? "choice_click" : "typed", clientActionId: requestId, }), }); if (!response.ok) { const payload = await response.json().catch(() => null); const message = payload?.message || payload?.error || `请求失败(${response.status})`; setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); return; } if (response.status === 402) { window.location.assign(membershipHref("rectification")); return; } if (response.status === 401) setError("请先登录。"); else setError(message); return; } if (!response.body) { setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setError("服务暂时不可用,请稍后再试。"); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let completed = false; let streamFailed = false; let runFailedMessage = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; let event: { type?: unknown; status?: unknown; text?: unknown; message?: unknown; tool?: unknown; methods?: unknown; turnId?: unknown; code?: unknown; activity?: unknown; }; try { event = JSON.parse(line) as typeof event; } catch { continue; } if (typeof event.type !== "string") continue; if (event.type === "answer.delta" && typeof event.text === "string") { raw += event.text; activityTrace = freezeLiveThink(activityTrace); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: raw, activityTrace, state: raw.trim() ? "streaming" : "thinking", activity: nextActivityView(message.activity, { phase: "answer-composition", label: "正在组织回答…", }), } : message)); } else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) { const activity = event.activity; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, activity: nextActivityView(message.activity, { phase: "evidence-validation", label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity], }), } : message)); } else if (event.type === "attempt.reset") { raw = ""; activityTrace = emptyActivityTrace(); activityReceiptState = createRectificationActivityReceiptState(); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); completedTurnId = undefined; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: "", thinkingText: undefined, activityTrace, state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined, activity: nextActivityView(undefined, { phase: "evidence-validation", label: "正在处理…", }), } : message)); } else if (event.type === "run.failed") { streamFailed = true; runFailedMessage = typeof event.message === "string" && event.message.trim() ? event.message : userFacingRunFailure(typeof event.code === "string" ? event.code : null); } else if (event.type === "error") { streamFailed = true; setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。"); } else if (event.type === "run.completed") { completed = true; if (typeof event.turnId === "string") completedTurnId = event.turnId; } else if (event.type === "tool.activity") { const tool = isPublicRectificationTool(event.tool) ? event.tool : null; if (!tool) continue; if (event.status === "started") { activityTrace = startActivityTraceStep( activityTrace, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], ); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, activityTrace, activity: nextActivityView(message.activity, { phase: rectificationToolActivityPhase(tool), label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool], completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), }), } : message)); continue; } if (event.status !== "completed" && event.status !== "failed") continue; 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); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, activityTrace, completedReceipt, activity: nextActivityView(message.activity, { phase: rectificationToolActivityPhase(tool), label: RECTIFICATION_TOOL_DONE_LABELS[tool], completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), }), } : message)); } } } const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (succeeded) { return [{ ...message, text: parsed.text, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: false, turnId: completedTurnId, activity: undefined, }]; } if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) { return [{ ...message, text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: true, activity: undefined, }]; } return []; })); if (!succeeded && raw.trim()) { setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。"); } else if (!succeeded && runFailedMessage) { setError((current) => current || runFailedMessage); } else if (!succeeded && completedReceipt.failedTool) { setError((current) => current || userFacingRunFailure("run_failed")); } if (succeeded) { onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), { role: "assistant", text: parsed.text }, ]); onCompleted?.(); await loadCaseSnapshot(); } } catch (caught) { const aborted = caught instanceof DOMException ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; if (aborted) { setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (raw.trim() || hasActivityReceipt(completedReceipt)) { return [{ ...message, text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: true, turnId: completedTurnId, }]; } return []; })); return; } setError("生时校正暂时不可用,请稍后再试。"); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; return hasActivityReceipt(completedReceipt) ? [{ ...message, text: "", state: "settled", completedReceipt, failed: true, }] : []; })); } finally { if (runAbort.current === abortController) runAbort.current = null; setPending(false); } }, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => { const key = stableChoiceActionKey(focusId, optionId); const existing = choiceActionIds.current.get(key); if (existing) return existing; const next = globalThis.crypto.randomUUID(); choiceActionIds.current.set(key, next); return next; }, []); const submitStructuredChoice = useCallback(async ( action: typeof CHOICE_ACTION | typeof STOP_ACTION, optionId: ChoiceKey | "stop", ) => { if (!choiceCard || busy || readonly) return; const focusId = choiceCard.focus_id; if (!isPersistedFocusId(focusId)) { setError("当前选择题已失效,请等待下一问。"); return; } const questionId = choiceCard.question_id; const actionId = actionIdForChoice(focusId, optionId); const userText = optionId === "stop" ? (choiceCard.stop_message || CHOICE_STOP_MESSAGE) : choiceCardUserMessage(choiceCard, optionId); setError(""); setPending(true); keyCounter.current += 1; const turnKey = keyCounter.current; const userRenderKey = `v9-choice-user-${turnKey}`; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; setMessages((current) => [ ...current, { role: "user", text: userText, renderKey: userRenderKey, state: "settled" }, { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer, startedAt: Date.now(), }, }, ]); try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ caseId, sessionId, requestId: actionId, action, actionId, focusId, questionId, probeId: choiceCard.probe_id, optionId: optionId === "stop" ? undefined : optionId, expectedRevision: choiceCard.case_revision ?? 0, origin: "choice_click", clientActionId: actionId, }), }); const payload = await response.json().catch(() => null); if (!response.ok) { setMessages((current) => current.filter((message) => ( message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey ))); setChoiceNonce((current) => current + 1); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); return; } setError(payload?.message || payload?.error || `请求失败(${response.status})`); return; } const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() : "已记录你的选择。"; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: narration, state: "settled" as const, activity: undefined, } : message)); onMessagesChange?.([ { role: "user", text: userText }, { role: "assistant", text: narration }, ]); onCompleted?.(); await loadCaseSnapshot(); if (shouldContinueAfterStructuredChoice(payload?.nextAction)) { choiceContinuationPending.current = true; } } catch { setMessages((current) => current.filter((message) => ( message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey ))); setChoiceNonce((current) => current + 1); setError("选择题处理失败,请稍后重试。"); } finally { setPending(false); } }, [ actionIdForChoice, busy, caseId, choiceCard, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, sessionId, setPending, ]); useEffect(() => { if (!choiceContinuationPending.current || busy || readonly) return; choiceContinuationPending.current = false; void send("read_only", ""); }, [busy, readonly, send]); useEffect(() => { if (initialTurns.length > 0) { if (shouldStartOpening) onOpeningConsumed?.(); return; } if (readonly || openingStarted.current || !shouldStartOpening) return; openingStarted.current = true; onOpeningConsumed?.(); void send("opening", ""); }, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]); const acceptCandidate = useCallback(async (candidateId: string) => { if (!candidateResult || acceptingCandidateId || busy || readonly) return; setError(""); setAcceptingCandidateId(candidateId); setPending(true); try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, resultId: candidateResult.resultId, candidateId, requestId: globalThis.crypto.randomUUID(), }), }, ); const payload = await response.json().catch(() => null); if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") { throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间"); } setCandidateResult((current) => { if (!current) return current; const selectedTime = typeof payload.saved_time === "string" ? payload.saved_time.slice(0, 5) : current.selectedTime; const next = { ...current, selectedTime, selectionKind: "user_accepted" as const, }; const houseTable = workingRectificationHouseTable(next); const recastMeaning = natalRecastMeaning(houseTable); return { ...next, houseTable: houseTable ?? current.houseTable, natalRecast: houseTable && recastMeaning ? { time: houseTable.time, lagna: houseTable.lagna, user_meaning: recastMeaning, unique_minute_claim: false as const, confirmation_allowed: false as const, } : current.natalRecast, }; }); setSavedTime(payload.saved_time); setSavedStatus("accepted"); onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); await loadCaseSnapshot(); choiceContinuationPending.current = true; } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingCandidateId(null); setPending(false); } }, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]); async function copyMessage(message: RenderMessage) { try { await navigator.clipboard.writeText(message.text); setCopiedMessageKey(message.renderKey); window.setTimeout(() => setCopiedMessageKey((current) => ( current === message.renderKey ? null : current )), 1_500); } catch { // Clipboard permission failures must not interrupt the conversation. } } async function regenerateMessage(message: RenderMessage) { if (!message.turnId || regeneratingMessageKey || busy || readonly) return; setError(""); setRegeneratingMessageKey(message.renderKey); const previousText = message.text; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/turns/${encodeURIComponent(message.turnId)}/regenerate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, requestId: globalThis.crypto.randomUUID(), }), }, ); const payload = await response.json().catch(() => null); if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") { throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答"); } setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: payload.assistantMessage, state: "settled" } : item)); } catch (caught) { setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: previousText, state: "settled" } : item)); setError(caught instanceof Error ? caught.message : "暂时无法重新生成回答"); } finally { setRegeneratingMessageKey((current) => current === message.renderKey ? null : current); } } async function submit(event: React.FormEvent) { event.preventDefault(); await send("message", draft); } const latestRegeneratableKey = [...messages] .reverse() .find((message) => ( message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.turnId) && Boolean(message.text) ))?.renderKey; const latestSettledAssistant = [...messages] .reverse() .find((message) => ( message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.text) )); const offeredSelectionOnce = messages.some(turnOfferedSelection); const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant)); const showChoiceCards = Boolean( choiceCard && latestSettledAssistant && !offeredThisTurn && !busy && !readonly && regeneratingMessageKey === null, ); useLayoutEffect(() => { choiceCardsOpen.current = showChoiceCards; updateFollowState(); }, [showChoiceCards, updateFollowState]); const showSelectionCards = Boolean( candidateResult?.selectionAllowed && offeredSelectionOnce && latestSettledAssistant && !showChoiceCards && !busy && regeneratingMessageKey === null, ); const selectionCardMessageKey = showSelectionCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; const choiceCardMessageKey = showChoiceCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; const canSend = !busy && !readonly && !regeneratingMessageKey; function submitChoice(key: ChoiceKey) { if (!choiceCard) return; void submitStructuredChoice(CHOICE_ACTION, key); } function submitStop() { if (!choiceCard) return; void submitStructuredChoice(STOP_ACTION, "stop"); } const boardPeek = compactBoard && !boardOpen ? ( ) : null; return (
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
{pendingConsultationQuestion?.trim() && (

先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”

)} {messages.map((message) => { const showActions = message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.text); const regenerating = regeneratingMessageKey === message.renderKey; const canRegenerate = message.renderKey === latestRegeneratableKey && !busy && !readonly && regeneratingMessageKey === null; const displayedMessage = regenerating ? { ...message, text: "", thinkingText: undefined, state: "thinking" as const, activity: nextActivityView(undefined, { phase: "answer-composition", label: "正在组织回答…", }), } : message; const vargaSentence = !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; return (
{message.state === "settled" && message.failed && Boolean(message.text) && (

回答未完成,已保留现有内容;本次不会扣点。

)} {(!message.failed || Boolean(displayedMessage.text) || regenerating) && ( )} {showActions && !regenerating && ( setFeedback((current) => ({ ...current, [message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested), }))} onCopy={() => void copyMessage(message)} onRegenerate={() => void regenerateMessage(message)} /> )} {showSelectionCards && message.renderKey === selectionCardMessageKey && candidateResult && ( void acceptCandidate(candidateId)} /> )} {showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && ( )}
); })} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime}

)} {savedTime && savedStatus === "accepted" && onStartConsultation && (
)} {error &&

{error}

} {readonly && (

该校正已结束,只能查看历史。

)}
{showJumpToLatest && (
)}