646 lines
26 KiB
TypeScript
646 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { parseAgentReplyBody } 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 { 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[];
|
||
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;
|
||
turnId?: string;
|
||
};
|
||
|
||
type PublicActivity = Readonly<{
|
||
tool: PublicRectificationTool;
|
||
label: string;
|
||
}>;
|
||
|
||
const ACTIVE_TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
||
"rectification-read-case": "正在读取校正记录…",
|
||
"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: [] };
|
||
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 current === requested ? undefined : requested;
|
||
}
|
||
|
||
function activityPhase(tool: PublicRectificationTool): NonNullable<ChatMessageView["activity"]>["phase"] {
|
||
if (tool === "rectification-compare-candidates" || tool === "rectification-read-diagnostics") {
|
||
return "chart-calculation";
|
||
}
|
||
return "evidence-validation";
|
||
}
|
||
|
||
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
|
||
return initialTurns.flatMap((turn, index): RenderMessage[] => {
|
||
const key = `persisted-${turn.id}-${index}`;
|
||
if (turn.role === "assistant") {
|
||
return [{
|
||
role: "assistant",
|
||
text: turn.text ?? "",
|
||
renderKey: key,
|
||
state: turn.status === "completed" ? "settled" : "thinking",
|
||
completedReceipt: completedReceiptFromPersisted(turn.receipt),
|
||
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<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||
const [draft, setDraft] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [savedTime, setSavedTime] = useState<string | null>(null);
|
||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
|
||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
|
||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
|
||
const conversation = useRef<HTMLElement>(null);
|
||
const composer = useRef<HTMLTextAreaElement>(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<CandidateResult> => {
|
||
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((item) => item.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((item) => item.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 = parseAgentReplyBody(raw);
|
||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||
? { ...message, text: parsed.text, state: "streaming", activeActivity: undefined }
|
||
: 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 = parseAgentReplyBody(raw);
|
||
const succeeded = completed && !streamFailed && Boolean(parsed.text);
|
||
setMessages((current) => succeeded
|
||
? current.map((message) => message.renderKey === assistantRenderKey
|
||
? {
|
||
...message,
|
||
text: parsed.text,
|
||
state: "settled",
|
||
activeActivity: undefined,
|
||
completedReceipt,
|
||
turnId: completedTurnId,
|
||
}
|
||
: message)
|
||
: current.filter((message) => message.renderKey !== assistantRenderKey));
|
||
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.filter((message) => message.renderKey !== assistantRenderKey));
|
||
} 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 (time: string) => {
|
||
if (!candidateResult || acceptingTime || readonly) return;
|
||
setError("");
|
||
setAcceptingTime(time);
|
||
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: time,
|
||
requestId: globalThis.crypto.randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
const payload = await response.json().catch(() => null);
|
||
if (!response.ok || payload?.ok !== true) {
|
||
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
|
||
}
|
||
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
|
||
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current);
|
||
setSavedTime(payload.saved_time);
|
||
setSavedStatus(status);
|
||
onSaved?.(payload.saved_time, status);
|
||
onCompleted?.();
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
||
} finally {
|
||
setAcceptingTime(null);
|
||
}
|
||
}, [acceptingTime, 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"
|
||
&& Boolean(message.turnId)
|
||
&& Boolean(message.text)
|
||
))?.renderKey;
|
||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||
|
||
return (
|
||
<>
|
||
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
|
||
<div className="message-list" aria-live="polite">
|
||
{pendingConsultationQuestion?.trim() && (
|
||
<p className="rectification-pending-note">
|
||
先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”
|
||
</p>
|
||
)}
|
||
{messages.map((message) => {
|
||
const showActions = message.role === "assistant"
|
||
&& message.state === "settled"
|
||
&& 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 (
|
||
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
|
||
{message.state === "settled" && message.completedReceipt && (
|
||
<CompletedActivityReceipt receipt={message.completedReceipt} />
|
||
)}
|
||
<ChatMessageRow
|
||
message={{
|
||
...displayedMessage,
|
||
activity: displayedMessage.activeActivity
|
||
? { phase: activityPhase(displayedMessage.activeActivity.tool), label: displayedMessage.activeActivity.label }
|
||
: undefined,
|
||
}}
|
||
showActivity={displayedMessage.state === "thinking" || Boolean(displayedMessage.activeActivity)}
|
||
/>
|
||
{showActions && !regenerating && (
|
||
<div className="rectification-message-actions" aria-label="Agent 回答操作">
|
||
<button
|
||
aria-label="赞"
|
||
aria-pressed={feedback[message.renderKey] === "up"}
|
||
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
|
||
title="赞"
|
||
type="button"
|
||
onClick={() => setFeedback((current) => ({
|
||
...current,
|
||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
|
||
}))}
|
||
>
|
||
<ThumbsUp aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
aria-label="踩"
|
||
aria-pressed={feedback[message.renderKey] === "down"}
|
||
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
|
||
title="踩"
|
||
type="button"
|
||
onClick={() => setFeedback((current) => ({
|
||
...current,
|
||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
|
||
}))}
|
||
>
|
||
<ThumbsDown aria-hidden="true" />
|
||
</button>
|
||
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
|
||
{copiedMessageKey === message.renderKey
|
||
? <Check aria-hidden="true" />
|
||
: <Copy aria-hidden="true" />}
|
||
</button>
|
||
<button
|
||
aria-label="重新生成回答"
|
||
disabled={!canRegenerate}
|
||
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
|
||
type="button"
|
||
onClick={() => void regenerateMessage(message)}
|
||
>
|
||
<RotateCcw aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
|
||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||
<div className="rectification-candidates-heading">
|
||
<strong>{candidateResult.confirmationAllowed ? "确认校正时间" : "当前可能的出生时间"}</strong>
|
||
<span>这是根据你目前提供的人生事件推算出的几种可能时间。可以先采用一个作为当前排盘时间,也可以继续补充事件;新增证据后,候选和相对支持度会重新计算,准确度还可以继续提高。</span>
|
||
<span>相对支持度不是统计概率,采用不会覆盖原始填报时间。</span>
|
||
</div>
|
||
<div className="rectification-candidate-list">
|
||
{candidateResult.candidates.map((candidate) => {
|
||
const selected = candidateResult.selectedTime === candidate.time;
|
||
const recommended = isRecommendedRectificationCandidate(candidateResult, candidate);
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`rectification-candidate${selected ? " is-selected" : ""}`}
|
||
key={`${candidateResult.resultId}-${candidate.time}`}
|
||
disabled={selected || Boolean(acceptingTime) || readonly}
|
||
onClick={() => void acceptCandidate(candidate.time)}
|
||
>
|
||
<span className="rectification-candidate-time">
|
||
<strong>{candidate.time}</strong>
|
||
{selected && <span className="rectification-candidate-badge">已采用</span>}
|
||
{recommended && <span className="rectification-candidate-badge">当前推荐</span>}
|
||
</span>
|
||
<span className="rectification-candidate-support">相对支持度 {candidate.relativeSupport}</span>
|
||
<span className="rectification-candidate-action">
|
||
{selected ? "已采用" : acceptingTime === candidate.time ? "正在采用…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
{savedTime && (
|
||
<p className="rectification-saved" role="status">
|
||
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。
|
||
</p>
|
||
)}
|
||
{error && <p className="error-message" role="alert">{error}</p>}
|
||
{readonly && (
|
||
<div className="rectification-terminal-actions">
|
||
<p className="rectification-terminal-note">该校正已结束,只能查看历史。</p>
|
||
<Button type="button" onClick={onRestart}>再次校正</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="composer-wrap">
|
||
<form className="composer" onSubmit={submit}>
|
||
<Textarea
|
||
ref={composer}
|
||
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||
value={draft}
|
||
disabled={!canSend}
|
||
placeholder={readonly
|
||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||
event.preventDefault();
|
||
event.currentTarget.form?.requestSubmit();
|
||
}
|
||
}}
|
||
/>
|
||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||
<ArrowUp aria-hidden="true" />
|
||
</Button>
|
||
</form>
|
||
|
||
<div className="composer-footer">
|
||
<ModelSelector
|
||
models={models}
|
||
selectedModelId={selectedModelId}
|
||
disabled={busy || readonly}
|
||
onSelect={onSelectModel}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|