"use client"; import { ArrowUp } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { parseAgentReply } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, type CompletedActivityReceiptView, } from "@/lib/rectification-activity-receipt"; import { isRecommendedRectificationCandidate, parseRectificationCandidateResult, type RectificationCandidateResult, } from "@/lib/rectification-candidate-result"; import { membershipHref } from "@/lib/membership"; import { isPublicRectificationMethod, isPublicRectificationTool, type PublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; import { ChatMessageActions, toggleChatMessageFeedback, } from "./chat-message-actions"; import { CompletedActivityReceipt } from "./completed-activity-receipt"; import { ModelSelector } from "./model-selector"; 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; 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; pendingConsultationQuestion?: string | null; onRestart?: () => void; }>; type RenderMessage = ChatMessageView & { renderKey: string; activeActivity?: PublicActivity; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; }; type PublicActivity = Readonly<{ tool: PublicRectificationTool; label: string; }>; const ACTIVE_TOOL_LABELS: Readonly> = { "rectification-read-case": "正在读取校正记录…", "rectification-set-focus": "正在设置对话焦点…", "rectification-resolve-focus": "正在处理当前焦点…", "rectification-record-evidence-batch": "正在整理多条事件证据…", "rectification-propose-evidence": "正在整理事件证据…", "rectification-confirm-evidence": "正在确认事件证据…", "rectification-revise-evidence": "正在修订事件证据…", "rectification-compare-candidates": "正在比较候选时间…", "rectification-read-diagnostics": "正在检查候选稳健性…", "rectification-offer-candidates": "正在生成候选建议…", "rectification-accept-candidate": "正在采用候选时间…", "rectification-confirm-birth-time": "正在确认校正时间…", "rectification-close-case": "正在完成校正记录…", }; 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 activityPhase(tool: PublicRectificationTool): NonNullable["phase"] { if (tool === "rectification-compare-candidates" || tool === "rectification-read-diagnostics") { return "chart-calculation"; } return "evidence-validation"; } 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); return [{ role: "assistant", text: failed ? "" : turn.text ?? "", renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt: completedReceiptFromPersisted(turn.receipt), failed, turnId: turn.id, }]; } 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, pendingConsultationQuestion, onRestart, } = 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 [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const conversation = useRef(null); const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); const setPending = useCallback((value: boolean) => { setBusy(value); onPendingChange?.(value); }, [onPendingChange]); useEffect(() => { const container = conversation.current; if (!container) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: container.scrollHeight, behavior: busy || reduceMotion ? "auto" : "smooth", }); }, [busy, error, messages, savedTime]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. const loadCandidate = useCallback(async (): Promise => { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store" }, ); if (!response.ok) return null; const payload = await response.json().catch(() => null); return parseRectificationCandidateResult(payload?.latest_result); } catch { return null; } }, [caseId, sessionId]); useEffect(() => { let active = true; void loadCandidate().then((result) => { if (active) setCandidateResult(result); }); return () => { active = false; }; }, [loadCandidate]); const send = useCallback(async (action: "opening" | "message", 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" }, ]); setDraft(""); let raw = ""; let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ caseId, sessionId, requestId, action, modelId: selectedModelId, ...(action === "message" ? { message: trimmed } : {}), }), }); 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; 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; }; 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; const parsed = parseAgentReply(raw); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming" } : message)); } else if (event.type === "run.failed") { streamFailed = true; } 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") { const activeActivity = { tool, label: ACTIVE_TOOL_LABELS[tool] } satisfies PublicActivity; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, activeActivity } : message)); continue; } if (event.status !== "completed" && event.status !== "failed") continue; 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, activeActivity: message.activeActivity?.tool === tool ? undefined : message.activeActivity, } : message)); } } } const parsed = parseAgentReply(raw); 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, state: "settled", activeActivity: undefined, completedReceipt, failed: false, turnId: completedTurnId, }]; } if (streamFailed || hasActivityReceipt(completedReceipt)) { return [{ ...message, text: "", state: "settled", activeActivity: undefined, completedReceipt, failed: true, }]; } return []; })); if (!succeeded && completedReceipt.failedTool) { setError(completedReceipt.failedTool === "rectification-compare-candidates" ? "候选比较未完成,当前进度已保留。" : "本轮处理未完成,当前进度已保留。请稍后再试。"); } if (succeeded) { onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), { role: "assistant", text: parsed.text }, ]); onCompleted?.(); await loadCandidate().then((result) => { if (result) setCandidateResult(result); }); } } catch { setError("生时校正暂时不可用,请稍后再试。"); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; return hasActivityReceipt(completedReceipt) ? [{ ...message, text: "", state: "settled", activeActivity: undefined, completedReceipt, failed: true, }] : []; })); } finally { setPending(false); } }, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); useEffect(() => { if (readonly || openingStarted.current || !shouldStartOpening) return; openingStarted.current = true; void send("opening", ""); }, [readonly, send, shouldStartOpening]); const acceptCandidate = useCallback(async (candidateId: string) => { if (!candidateResult || acceptingCandidateId || readonly) return; setError(""); setAcceptingCandidateId(candidateId); 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) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: "user_accepted" } : current); setSavedTime(payload.saved_time); setSavedStatus("accepted"); onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingCandidateId(null); } }, [acceptingCandidateId, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]); 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 canSend = !busy && !readonly && !regeneratingMessageKey; return ( <>
{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: "", state: "thinking" as const, activeActivity: undefined } : message; return (
{message.state === "settled" && message.failed && !message.completedReceipt?.failedTool && (

本轮处理未完成,已保留服务端记录的执行进度。

)} {message.state === "settled" && message.completedReceipt && ( )} {(!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)} /> )}
); })} {candidateResult?.selectionAllowed && (
当前可能的出生时间 这次校正的结果是采用一个代表性时间作当前排盘。相邻分钟目前分不开,还不能确认唯一分钟。可以先采用,也可以继续补充事件或改选;新增证据后,候选和相对支持度会重新计算。 相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。
{candidateResult.candidates.map((candidate) => { const selected = candidateResult.selectedTime === candidate.time; const recommended = isRecommendedRectificationCandidate(candidateResult, candidate); return ( ); })}
)} {savedTime && (

{savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性候选,还不能确认唯一分钟)"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。

)} {error &&

{error}

} {readonly && (

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

)}