"use client"; import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { AgentActivityStatus, type AgentActivityState } from "@/components/agent-activity-status"; import { useRectificationV4 } from "@/hooks/use-rectification-v4"; import type { ChatMessageView } from "@/lib/chat-message-view"; import type { PublicLanguageModel } from "@/lib/public-models"; import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationAssistantMessage, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts"; import { AgentAvatar, ChatMessageRow } from "./chat-message-row"; import { ModelSelector } from "./model-selector"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; export type RectificationV4Continuation = Readonly<{ protocol: "rectification-evidence-v4"; question: string; caseId: string; caseVersion: number; acceptedRange: Readonly<{ start: string; end: string }>; }>; type RectificationV4PanelProps = Readonly<{ models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; pendingConsultationQuestion?: string | null; continuationPending?: boolean; onPendingChange?: (pending: boolean) => void; onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void; onUseAgentic?: () => void; }>; type RectificationChatMessageView = ChatMessageView & Readonly<{ analysisTrace?: RectificationAnalysisTrace; activityState?: AgentActivityState; }>; type RectificationPhase = NonNullable["phase"]; const phaseLabels = { collecting_evidence: "正在准备继续收集经历…", extracting_evidence: "正在整理你刚才提到的经历…", scoring_candidates: "正在扫描候选时间…", checking_robustness: "正在检查候选范围的稳定性…", planning_question: "正在生成语义问题机会…", reasoning: "正在选择下一步动作…", rendering: "正在生成安全回复…", complete: "分析已完成", } as const; const phaseActivityStates = { collecting_evidence: "listening", extracting_evidence: "working", scoring_candidates: "searching", checking_robustness: "solving", planning_question: "shaping", reasoning: "solving", rendering: "composing", complete: "shaping", } as const satisfies Record; export function rectificationPhaseLabel( phase: RectificationPhase, ): string { return phaseLabels[phase]; } export function rectificationProgressLabel( phase: RectificationPhase, ): string { return rectificationPhaseLabel(phase); } export function rectificationPhaseActivityState(phase: RectificationPhase): AgentActivityState { return phaseActivityStates[phase]; } function durationLabel(durationMs: number | null): string | null { if (durationMs === null || durationMs < 0) return null; return durationMs < 1_000 ? `${durationMs} 毫秒` : `${(durationMs / 1_000).toFixed(1)} 秒`; } function publicStatusLabel(status: string): string { return ({ completed: "已完成", succeeded: "已完成", running: "进行中", failed: "未完成", skipped: "已跳过", legacy: "历史记录", } as Record)[status] ?? "已记录"; } function RectificationAnalysisDetails({ trace }: Readonly<{ trace: RectificationAnalysisTrace }>) { return (
分析过程 {publicStatusLabel(trace.status)}
{trace.stages.length > 0 && (

执行阶段

    {trace.stages.map((stage, index) => { const duration = durationLabel(stage.durationMs); return (
  1. {stage.label} {publicStatusLabel(stage.status)}{duration ? ` · ${duration}` : ""}
  2. ); })}
)} {trace.toolCalls.length > 0 && (

实际调用

    {trace.toolCalls.map((toolCall, index) => { const duration = durationLabel(toolCall.durationMs); return (
  • {toolCall.label} {publicStatusLabel(toolCall.outcome)}{duration ? ` · ${duration}` : ""}
  • ); })}
)} {trace.techniques.length > 0 && (

实际使用的技法

{trace.techniques.join("、")}

)} {trace.reasoningSource === "provider_summary" && trace.reasoningSummary && (

推理摘要

{trace.reasoningSummary}

)}
); } function RectificationMessageRow({ message }: Readonly<{ message: RectificationChatMessageView }>) { if (message.state !== "thinking" || !message.text) return ; return (
); } export function toggleRectificationFeedback( current: "up" | "down" | undefined, requested: "up" | "down", ): "up" | "down" | undefined { return current === requested ? undefined : requested; } export function canRegenerateRectificationMessage(input: Readonly<{ message: ChatMessageView; currentMessageKey: string | null; deploymentMode: RectificationV4ApiResponse["case"]["deploymentMode"] | null; busy: boolean; canAnswer: boolean; }>): boolean { return input.deploymentMode === "v5_agent" && input.message.role === "assistant" && input.message.state === "settled" && input.message.renderKey === input.currentMessageKey && !input.busy && input.canAnswer; } export function composeRectificationAssistantMessage(message: RectificationAssistantMessage): string { return [message.acknowledgement, message.evidenceExplanation, message.candidateUpdate, message.limitation, message.question] .filter((value): value is string => Boolean(value?.trim())) .join("\n\n"); } export function rectificationV4ChatMessages( data: RectificationV4ApiResponse | null, processing: boolean, pendingConsultationQuestion?: string | null, ): readonly RectificationChatMessageView[] { if (!data) { return [{ role: "assistant", text: "", renderKey: "rectification-loading", state: "thinking", }]; } const messages: RectificationChatMessageView[] = []; const assistantResponses = data.case.deploymentMode === "v5_agent" ? data.assistantResponses ?? [] : []; const responseBySourceTurnId = new Map(assistantResponses.map((item) => [item.sourceTurnId, item])); const analysis = data.case.deploymentMode === "v5_agent" ? (data as RectificationV4ApiResponse & { readonly analysis?: readonly RectificationAnalysisItem[]; }).analysis ?? [] : []; const analysisBySourceTurnId = new Map(analysis.map((item) => [item.sourceTurnId, item.trace])); if (pendingConsultationQuestion?.trim()) { messages.push({ role: "assistant", text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingConsultationQuestion.trim()}”`, renderKey: "rectification-pending-consultation", state: "settled", }); } let previousTurn: RectificationV4ApiResponse["turns"][number] | undefined; for (const turn of data.turns) { if (!previousTurn || !responseBySourceTurnId.has(previousTurn.id)) { messages.push({ role: "assistant", text: turn.question, renderKey: `rectification-question-${turn.id}`, state: "settled", analysisTrace: previousTurn ? analysisBySourceTurnId.get(previousTurn.id) : undefined, }); } if (turn.answer) { messages.push({ role: "user", text: turn.answer, renderKey: `rectification-answer-${turn.id}`, state: "settled", }); } const response = responseBySourceTurnId.get(turn.id); if (response) { messages.push({ role: "assistant", text: composeRectificationAssistantMessage(response.message), renderKey: `rectification-response-${turn.id}`, state: "settled", analysisTrace: response.trace ?? undefined, }); } previousTurn = turn; } const caseValue = data.case; const primary = caseValue.latestSnapshot?.clusters[0]; const latestTurn = data.turns.at(-1); const latestResponse = latestTurn ? responseBySourceTurnId.get(latestTurn.id) : undefined; const latestTurnTrace = latestResponse?.trace ?? analysisBySourceTurnId.get(latestTurn?.id ?? ""); const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace ?? undefined; if (caseValue.acceptedRange) { messages.push({ role: "assistant", text: `候选范围已保存为 ${caseValue.acceptedRange.start}–${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`, renderKey: `rectification-accepted-${caseValue.version}`, state: "settled", analysisTrace: terminalTrace, }); } else if (!latestResponse && caseValue.status === "range_ready" && primary) { messages.push({ role: "assistant", text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}–${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`, renderKey: `rectification-range-${caseValue.version}`, state: "settled", analysisTrace: terminalTrace, }); } if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange && !latestResponse) { messages.push({ role: "assistant", text: caseValue.currentQuestion.prompt, renderKey: `rectification-current-${caseValue.currentQuestion.id}`, state: "settled", analysisTrace: latestTurnTrace ?? undefined, }); } if (processing) { const phase = data.job?.phase ?? caseValue.phase; messages.push({ role: "assistant", text: rectificationProgressLabel(phase), renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`, state: "thinking", activityState: rectificationPhaseActivityState(phase), }); } else if (caseValue.status === "paused" && !latestResponse) { messages.push({ role: "assistant", text: "进度已经保存。准备好后,我们可以从这里继续。", renderKey: `rectification-paused-${caseValue.version}`, state: "settled", analysisTrace: terminalTrace, }); } else if (caseValue.status === "abandoned") { messages.push({ role: "assistant", text: "这次校正已经结束,原出生时间没有被改写。", renderKey: `rectification-abandoned-${caseValue.version}`, state: "settled", analysisTrace: terminalTrace, }); } return messages; } export function RectificationV4Panel(props: RectificationV4PanelProps) { const controller = useRectificationV4({ pendingConsultationQuestion: props.pendingConsultationQuestion, onPendingChange: props.onPendingChange, }); const [draft, setDraft] = useState(""); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const composer = useRef(null); const conversationEnd = useRef(null); const caseValue = controller.data?.case; const processing = Boolean(caseValue && ( caseValue.status === "processing" || ["pending", "processing"].includes(controller.job?.status ?? "") )); const messages = rectificationV4ChatMessages( controller.data, processing, props.pendingConsultationQuestion, ); const canAnswer = Boolean(caseValue?.currentQuestion) && !processing && !controller.pending && ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? ""); const currentMessageKey = caseValue?.currentQuestion ? `rectification-current-${caseValue.currentQuestion.id}` : null; const busy = processing || controller.pending || regeneratingMessageKey !== null; const canAcceptRange = caseValue?.status === "range_ready" && Boolean(caseValue.latestSnapshot?.canAcceptRange) && !caseValue.acceptedRange; const handoff = controller.handoff; const canContinue = Boolean( caseValue?.acceptedRange && handoff?.status === "pending" && props.onContinueOriginalQuestion, ); const canUseAgentic = Boolean(caseValue && caseValue.status !== "abandoned" && props.onUseAgentic); const showControls = Boolean(caseValue && caseValue.status !== "abandoned" && ( canAnswer || canAcceptRange || canContinue || caseValue.status === "paused" || canUseAgentic )); useEffect(() => { const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; conversationEnd.current?.scrollIntoView({ behavior: processing || controller.pending || reduceMotion ? "auto" : "smooth", block: "end", }); }, [controller.error, controller.pending, messages.length, processing]); async function submit(event: React.FormEvent) { event.preventDefault(); const answer = draft.trim(); if (!answer || !canAnswer) return; const result = await controller.answer(answer, props.selectedModelId || null); if (result) setDraft(""); } async function copyMessage(message: ChatMessageView) { 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(messageKey: string) { setRegeneratingMessageKey(messageKey); try { await controller.regenerate(); } finally { setRegeneratingMessageKey((current) => current === messageKey ? null : current); } } function continueOriginalQuestion() { if (!caseValue?.acceptedRange || !handoff) return; props.onContinueOriginalQuestion?.({ protocol: "rectification-evidence-v4", question: handoff.question, caseId: caseValue.id, caseVersion: caseValue.version, acceptedRange: caseValue.acceptedRange, }); } async function startAgentic() { const result = await controller.abandon(); if (result) props.onUseAgentic?.(); } return ( <>
{messages.map((message) => { const showActions = caseValue?.deploymentMode === "v5_agent" && message.role === "assistant" && message.state === "settled" && Boolean(message.text); const regenerating = regeneratingMessageKey === message.renderKey; const canRegenerate = canRegenerateRectificationMessage({ message, currentMessageKey, deploymentMode: caseValue?.deploymentMode ?? null, busy, canAnswer, }); return (
{message.role === "assistant" && message.state === "settled" && message.analysisTrace && ( )} {showActions && !regenerating && (
)}
); })} {controller.data?.runtimeTrace && } {controller.error &&

{controller.error}

}
{showControls && (
{(canAcceptRange || canContinue || caseValue?.status === "paused" || canUseAgentic) && (
{canAcceptRange && ( )} {canContinue && ( )} {caseValue?.status === "paused" && ( )} {canUseAgentic && ( )}
)} {canAnswer && (